基于 LangChain / LangGraph 个人博客智能助手

返回项目列表
基于 LangChain / LangGraph 个人博客智能助手
已发布
Python/LangGraph/LangChain/RAG/Text-to-SQL

把博客变成一个能对话的人:LangGraph 四张图做意图路由,向量召回 + Text-to-SQL 两条证据链,DeepSeek 驱动,桌宠「爱弥斯」就是它的前端。全程只读数据库。

项目简介#

这是本站右下角那只桌宠「爱弥斯」的后端大脑:一个用 LangChain + LangGraph 写的个人博客智能助手。

它的活儿很具体 —— 访客在博客里跟她说话,她得真的读过这个站:说”推荐几篇讲 Linux 的”,她要能挑出站内真实的文章(带真实标题和链接);说”讲讲那篇讲 HTTP 的”,她要能定位到那一篇并按原文讲;问”你是谁”,她要用爱弥斯的口吻回答;聊别的,她就正常聊。换个说法就是:她没有”编”的权限,只依据取到的数据说话。

整个服务由 langgraph dev 直接拉起(LangGraph Server),注册了 4 张图,主图负责意图路由,另外三张子图分别管推荐、介绍、闲聊。

效果与数据规模#

项规模
图(graph)4 张:blog_agent(主图)+ recommended_agent / introduce_agent / extend_agent
后端代码src/agent 共 2254 行 Python(不含测试与脚本)
检索语料站内 133 篇文章的「标题 + 摘要」→ 向量索引 data/blog_index.json(16.2 MB)
数据源博客的 MySQL 库,只读访问(只有 SELECT)
测试20 个测试文件 / 149 条用例(单元 + 集成),两条 CI
LLMDeepSeek 官方 OpenAI 兼容接口,两个实例:路由用 temperature=0、对话用 temperature=0.3

整体架构#

访客 ──► 博客前端(桌宠气泡)
│ 同源反向代理,API Key 不出服务端
▼
LangGraph Server(langgraph dev,2024 端口)
│
▼
blog_agent(主图)
get_store_info ─► identify_question ─► 意图路由
│
┌──────────────────┬───────────────────────┼──────────────────────┐
▼ ▼ ▼ ▼
recommended_graph introduce_graph get_user_preferences extend_graph
(推荐,向量 + SQL) (介绍某篇文章) (读用户偏好记忆) (闲聊兜底)
│ │ │ │
└──► need_introduce ┘ └──────────────────────┘
(要不要挑一篇讲讲) │
└──────────────────────────────► END

主图自己的骨架只有 7 个节点、102 行;真正的业务逻辑分别落在三张子图里。前端只调一张图(blog_agent),路由发生在服务端图内部 —— 以后加一条新意图,前端一行都不用改。

主图:一句话进来,先判断它想干什么#

第一步是结构化输出的意图分类(不用关键词表,交给模型判断),然后按结果分流:

def router_message(state: State):
user_intent = state["user_intent"]
if user_intent == "recommend_blog":
return "recommended_graph" # 想找文章
elif user_intent == "get_info":
return "get_user_preferences" # 问"我的"偏好/记忆
elif user_intent == "introduce_blog":
return "introduce_graph" # 点名要讲某一篇
else:
return "extend_graph" # 其余全部当闲聊

两个细节值得单独说:

  • temperature=0 的路由实例与对话实例分开。分类要的是稳定(同一句话永远进同一条路),说话要的是自然 —— 两个实例各管一头,互不将就。
  • 推荐跑完之后不是立刻结束,而是先反问一句「要不要我挑其中一篇讲讲」—— 这是 LangGraph 的 interrupt() 人机交互:图在这里停下来,把问题交给前端,访客答一个序号后用 command.resume 接着原来的状态往下跑,进介绍子图。所以”先推荐、再挑一篇细讲”是一次对话里的连续两跳,而不是两次互不相干的请求。

两条证据链:向量召回 + Text-to-SQL#

推荐和介绍看着像一件事,走的却是两种相反的取数顺序:

子图先做什么为什么
推荐(recommend.py,724 行)向量优先:把这句话丢给检索,拿回候选 id,再按 id 走 SQL 取结构化字段用户给的只是”模糊方向”(“讲 Linux 的”),向量是概率性的,正好用来”由意思找文章”
介绍(introduce.py,321 行)标题优先:先在标题上精确匹配,匹配不到才向量兜底用户点名了一篇,篇名就是精确证据;这时候让向量”猜”反而会把话题带偏

检索本身抽成了一个只有一个函数的模块(common/retrieval.py):给它一句话,返回最像的几篇的 id;调用方拿着这些 id 拼一条 WHERE id IN (...) 的 SQL,剩下的交给原来的取数链路。

search_ids(question)
[(id, 分数), ...] # 挑到了,按余弦相似度降序
[] # 索引没问题,但库里没有这个方向 —— 如实说"没找到"
None # 索引没建 / embedding 接口不可用 —— 调用方回退纯 SQL

三种返回值必须分开处理,因为它们的用户可见含义完全不同:“没找到”和”我查不了”如果都当成”没找到”,就会变成一个会撒谎的助手。索引由 scripts/build_index.py 离线建好 dump 成 JSON(只读库、只发 SELECT,跑一次就够);向量是建库时算的,所以换 embedding 模型时重跑一次建库脚本,保证查询与索引用的是同一个模型。

只读取数:两条实现选择#

助手要读博客的库,但只读:连接串只发 SELECT,另有一个脚本专门验证这一点。围绕”只读”这件事有两条明确的实现选择:

  • 连接按需建立(懒加载)。SQLDatabase.__init__ 会 inspect(self._engine),也就是构造时就连一次库,所以连接被抽到独立模块、用到才连。这样”建图”与”在吗”这类不碰数据的对话完全不依赖数据库的可用性,取数路径自己决定拿不到数据时说什么。
  • 直接取结构化行,不经过字符串化的中间层。上游的 SQLDatabase.run() 返回的是字符串化的 Python 字面量(调用方得再 literal_eval 解一遍、再按列名对齐),并且会把每个字符串列截断到 300 字。这里改走原生 SQLAlchemy 连接:一次拿到结构化行,长正文完整不截断,还能用 :name 参数绑定,省掉手拼 SQL。

两条选择服务于同一件事:取数层的行为要可预期——要么给出完整、干净的数据,要么明确地表示这次拿不到。

人设:她为什么一直像同一个人#

面向用户说话的节点有三个(闲聊、偏好、介绍),人设被抽成了单一来源:一份 persona.txt + 一个 with_persona(任务说明) 包装函数 —— 三个节点共用同一份口吻定义,每个节点只描述”这一轮要干什么”。改口吻只改一处,三个场景同时生效。

人设里写死的硬边界包括:只依据检索结果说话,不许编造文章标题、链接、字数;查不到就说查不到;被问身份时明确自己是「爱弥斯」(但不许宣称自己是真人、也不许冒充官方角色 —— 这条底线优先于前一条);称呼访客用”你”而不是”您”;每段最多一个 emoji。

人设文件放在代码里而不是数据库里:它是【静态配置】,不是【用户数据】—— 放文件有 Git 版本、能 review、能回滚、零网络延迟、不依赖数据库可用性,改完重启服务即生效。

取不到数的时候,她不会硬编#

这是整个项目花心思最多的地方:每一条取数路径都有自己的出口,出口写的是”这时候她该说什么”,而不是把错误抛给访客。

这一层拿不到数据时她的行为
向量索引没建 / embedding 服务不可用自动回退纯 SQL 取数,功能继续
数据库连不上推荐子图一条都拿不出来 → 主图直接收场,不再追问”要不要讲讲其中一篇”(一条候选都没有还追问就是自说自话)
模型答了不该上屏的内容前端有一道禁词闸门:命中就不上屏,改走兜底台词
后端整体不可达桌宠换成”我这边一时接不上话”的兜底台词,且连续两次不重样,访客一眼能看出这次不是她答的

原则只有一条:宁可她明说”我这边取不到数”,也不要她编一篇不存在的文章。 一个会编链接的助手,比一个会承认拿不到数据的助手危险得多。

怎么跑起来#

Terminal window
cp .env.example .env # 填 DEEPSEEK_API_KEY(这个文件不进仓库)
pip install -e . "langgraph-cli[inmem]"
langgraph dev # 起 LangGraph Server + Studio(可视化看图的每一步状态)

.env 里除了密钥,还能覆盖 DEEPSEEK_BASE_URL / DEEPSEEK_MODEL —— 因为走的是 OpenAI 兼容协议,换服务商只改这两行。开 LangSmith 追踪只需再填一个 key。

一条部署注意:.env 保持 ASCII、注释单独占一行。python-dotenv 按系统区域编码读这个文件(中文 Windows 是 GBK),行尾的中文注释有可能被读进值里。

小结#

这个项目让我第一次把”让模型说话”和”让系统可信”当成两件事来做:模型只负责组织语言,事实必须来自检索到的数据;而”检索不到""库连不上""模型越界”这些情况,全都要有明确的、用户看得懂的出口。做完之后回头看,真正难的不是把 LangGraph 连起来 —— 是每一种取不到数的情况都要想清楚:这时候她该说什么,才既诚实又不让人出戏。

Profile Image of the Author
HandlerOne
おかえり
公告
本站已整理 138 篇技术笔记,并按 8 条主线排成学习路线(141 站);站内搜索与桌宠对话均已上线。
分类
标签
最新动态
站点统计
文章
138
分类
12
标签
42
总字数
597,044
站点信息
构建平台
腾讯云 · Nginx
博客版本
v6.16.8
文章目录