基于 LangChain / LangGraph 个人博客智能助手
把博客变成一个能对话的人: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 |
| LLM | DeepSeek 官方 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 取数,功能继续 |
| 数据库连不上 | 推荐子图一条都拿不出来 → 主图直接收场,不再追问”要不要讲讲其中一篇”(一条候选都没有还追问就是自说自话) |
| 模型答了不该上屏的内容 | 前端有一道禁词闸门:命中就不上屏,改走兜底台词 |
| 后端整体不可达 | 桌宠换成”我这边一时接不上话”的兜底台词,且连续两次不重样,访客一眼能看出这次不是她答的 |
原则只有一条:宁可她明说”我这边取不到数”,也不要她编一篇不存在的文章。 一个会编链接的助手,比一个会承认拿不到数据的助手危险得多。
怎么跑起来
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 连起来 —— 是每一种取不到数的情况都要想清楚:这时候她该说什么,才既诚实又不让人出戏。

