我用 LangGraph 从零搭了个"反谣言"搜索引擎
一个前端背景的程序员,学习 Python + AI Agent 管道的全记录。
灵感来自知乎"直答 Agent"
今年年初,知乎开放了他们的直答 Agent API。
说白了就一件事:你提一个问题,它自动去搜索引擎搜、读网页内容、整合分析、给你一个带来源引用的回答。不需要自己搭知识库,不需要折腾 RAG 管线------搜索引擎就是你的"数据库",各大引擎的 API Key 一配就能跑。
它提供了三档模式,挺聪明:
| 模式 | 干什么的 | 耗时 |
|---|---|---|
| 简单模式 | 快速直答,适合"今天天气"这种问题 | 秒级 |
| 深度模式 | 基于知乎知识库的深度分析 | 几十秒 |
| DeepSearch | 实时多引擎搜索 + 多源整合 + 综合分析 | 几分钟 |
我当时就在想:这其实就是个搜索类 Agent 啊。
LangChain 学了点、LangGraph 翻了三天文档没看懂、Agent 概念模模糊糊。但你让我直接上手写一个?好像可以。因为搜索类 Agent 有个天然优势------数据不用操心。
不做知识库就不需要处理文档切分和 Embedding 质量;不做 RAG 就不需要调 chunk size 和检索策略;不微调模型就不需要准备数据集。数据全在搜索引擎里,你要做的就是:搜 → 读 → 分析 → 回答。链路清晰得跟流水线一样。
对于想学 AI Agent 的新手来说,这是个绝佳的切入点------API 接入简单、链路可见、效果立竿见影。于是我就动手了。
背景:AI 把岗位卷没了,我选择主动拥抱它
2026 年 2 月,公司宣布我"毕业"了。
说好听点叫优化,说难听点就是被 AI 卷掉了。我写了七八年 React,组件树倒背如流,Webpack 配置闭眼调。但今年再刷招聘市场,纯前端岗位缩水了不止一半。简历投出去,回复越来越像 ChatGPT 写的------"你很优秀,但目前我们更需要具备 AI 工程化能力的全栈工程师"。
焦虑是真的焦虑。身边不少同行在抱怨"AI 抢饭碗",但我觉得与其抱怨,不如动手学。以前是 AI 替代我,那我能不能反过来用它?把 AI 当工具,用它做出以前一个人根本做不出来的东西?
还没缓过来,家里事情就一件接一件。索性在家待着,一边处理家事一边把一直想搞的 AI 方向认真补了补。
说是"补",其实就是硬啃。以前写 React、调 Webpack,脑子里全是组件树和状态管理。突然要理解 Embedding、RAG、Agent 循环、状态机编排这些概念,坦白说中间卡了很多次。对着 LangGraph 文档看了三天没看懂,最后是边抄代码边跑才慢慢明白的。
这个 TruthSeeker 就是我在家鼓捣出来的。一个用 LangGraph 搭的深度研究引擎,前端、后端、模型调用、部署,全是我一个人弄的。说实话过程挺狼狈的------经常半夜对着一堆报错发呆。但每解决一个问题,那种"原来是这样"的感觉,比写一百个 React 组件都爽。
这个项目有一个贯穿始终的硬约束:预算为零,Token 太贵了。
我没有公司报销 API 费用,用的都是自己充值的 DeepSeek 余额。深度研究模式跑一轮,意图分析 + 多引擎搜索 + 原子声明提取 + 信源画像 + 三方共识 + 最终裁决,加起来可能调用十几次 LLM。如果用 GPT-4o,一次深度研究的推理成本可能好几块钱。一个月跑几百次,还没找到工作就先破产了。
所以你要是看到后文各种"为什么不那样做"的决策,别觉得奇怪------它们背后都有一个统一的原因:穷。为什么选 DeepSeek?便宜啊。为什么搞两级过滤?少喂 Token 给 LLM,省一分是一分。为什么最多搜 3 轮?多一轮都是真金白银。为什么把模型切成"搜索用便宜的、验证用强的"?好钢得用在刀刃上。这篇文章里几乎所有技术决策,都可以归结为一句话:穷有穷的做法。
我不觉得这是"转行"。前端工程师的核心能力从来不是写 JSX,而是理解用户需求、设计交互逻辑、工程化思维。这些能力换个语言、换个领域一样能用。LangGraph 的状态机不就是 Redux 换个马甲吗?管道的条件路由不就是 React Router 吗?真正的壁垒不是语言,是你愿不愿意从头开始。
这篇文章记录的是搭建全过程:踩过的坑、做过的决策、以及 POC 阶段来不及解决的妥协。不一定都对,但每一步都踩得真实。给同样面临焦虑、正在考虑转型的前端同行一个参考。
一、TruthSeeker 到底做什么?
先回答最直接的问题:这玩意儿能干嘛?
简单说,你提一个问题,它去全网搜,然后把不同来源的说法摆在一起对比,告诉你哪些是真的、哪些是矛盾的、哪些根本没法验证。
不追求"给你一个答案",而是追求"告诉你这个答案可信吗"。
比如你问:"Neuralink 首例人体植入后受试者出现感染,真的假的?"它会:
- 把你的问题拆成几个子问题:植入时间、受试者状态、感染报告来源
- 同时去多个搜索引擎搜(博查、Tavily、知乎)
- 把所有搜到的网页里的事实拆成"原子声明"------比如"手术于 2024 年 1 月完成"
- 对每条声明,检查有多少来源在说同样的事,这些来源靠谱吗
- 最终给你一份报告:哪些事实被多个权威信源证实,哪些只有一个来源在传,哪些来源之间互相矛盾
核心跟普通搜索的区别在于那个交叉验证环节------我后来管它叫"审判室"。
四个档位,丰俭由人
不是所有问题都需要这么重的流程,所以做了四个档位:
| 模式 | 适合场景 | 耗时 |
|---|---|---|
| 极速快问 | 简单确认,比如查一个价格 | 几秒 |
| 专家搜索 | 深入了解一个主题 | 几十秒 |
| 深度研究 | 复杂问题、需要多源验证 | 几分钟 |
| 智能模式 | 让 AI 自己判断该用哪个 | 自适应 |
这个设计思路跟知乎直答 Agent 的三档模式很像------都是从"秒回"到"深度挖掘"的分层策略。但 TruthSeeker 比它多了一个关键环节:交叉验证。不是搜完就直接回答,而是让不同信源"对质"之后再下结论。
二、技术选型的酸甜苦辣
我一个写了七八年 JavaScript 的人,突然要搭一个 Python 后端的 AI 项目,说实话一开始是有点抗拒的。但 LLM 生态最好的工具链全在 Python 侧------LangChain、LangGraph、FastAPI------这是现实,没什么好纠结的。
整体架构
系统大概分三块:用户交互的前端、处理请求的后端、以及真正干活的 Worker 进程。为什么要把后端和 Worker 拆开?因为深度研究要跑几分钟,不能让 HTTP 请求一直挂着等------后端收到请求就丢进队列,Worker 在后台慢慢跑,结果通过 SSE 实时推回去。
FastAPI vs Django:选轻的,别选全的
Django 太重了。这个项目需要的是:路由层、中间件、异步支持、SSE 流式响应。不需要 ORM 自带的后台管理、不需要模板引擎、不需要表单验证------这些我前端都自己做了。
FastAPI 的好处:原生 async/await、Pydantic 请求校验、Swagger UI 自动生成、启动快。但后悔的是: FastAPI 的依赖注入系统一开始用得很爽,项目大了以后到处 Depends() 让调用链很难追踪。如果有下次,我会把业务逻辑更多放在 service 层,路由只做参数校验和转发。
LangGraph vs 纯 LangChain:状态机才是正道
刚开始我用的是 LangChain 的 LLMChain,就是最简单的"给一个 Prompt,拿一个回答"。很快发现两个问题:
- 流程不可控。研究需要多步走(意图分析 → 搜索 → 验证 → 报告),不是一次 LLM 调用能搞定的。用 Chain 串联虽然能做,但中间状态断了就全丢了。
- 需要循环。验证发现信源矛盾,得回头重新搜索。这种"条件路由+循环"在 LangChain 里写起来很痛苦。
LangGraph 解决的就是这两个问题:它把整个流程定义成一个状态机,每个节点是独立的处理步骤,节点之间可以条件跳转,而且每一步的状态自动持久化------等于自带断点续传。
当时没对比的: 应该对比 LangFlow(可视化编排)和 Haystack。但当时 LangGraph 文档最全、例子最多,就它了。这不算错,但算偷懒。
SQLite → PostgreSQL:本地一时爽,生产火葬场
本地开发一直用 SQLite,不需要装任何东西,pip install 完就能跑。但 SQLite 有个致命问题:并发写入锁。当 Worker 在写研究结果、同时 API 在查历史记录,SQLite 的写锁会导致读超时。生产环境果断切到 PostgreSQL。
教训: 如果一开始就知道要上生产,直接 PG 起步。
数据库迁移工具用了 Alembic ------SQLAlchemy 官方迁移工具,自动生成迁移脚本、版本管理和回滚。Docker Compose 启动时先跑 alembic upgrade head,保证 schema 和代码永远对齐。
Redis 扛三个角色:为什么不用 RabbitMQ?
Redis 在这个项目里干了三件事,这是一个反复纠结后做的取舍:
| 角色 | 怎么用的 | 为什么是 Redis |
|---|---|---|
| 任务队列 | Worker 通过 BRPOP 拉取任务 | 阻塞弹出天然支持优先级队列 |
| SSE 发布/订阅 | Worker 实时推送进度给 API | PubSub 延迟接近零 |
| 缓存 | LLM 重复请求缓存 | 内存读写比 PG 快两个数量级 |
用 Redis 扛三个角色最大的好处是运维一致------Docker Compose 里只多一个服务。代价是 Redis PubSub 不保证消息送达(断线就丢消息),后面会用 Redis Stream 补救。
三层配置模型:想了最久的设计
用户的 API Key、用户的模型列表、用户的研究策略------这三样东西不能混在一起存:
比如:用户配了 DeepSeek 的 API Key(凭证层),注册了 deepseek-chat 和 deepseek-reasoner 两个模型(资产层),然后创建一个 Preset 说"意图分析用 chat 模型、验证用 reasoner 模型"(策略层)。三层解耦后,换 API Key 不需要重配策略,加新模型也不需要改预设。
三、把研究拆成 6 个工人
最初的版本极其朴素:用户提问 → 搜索 → 把搜索结果喂给 LLM → 生成回答。十几个 LLMChain 串起来,跑得动,但问题一大堆:
- 中间结果丢了。 验证到一半报错------你得从头再跑。
- 没法回头。 验证发现某个维度证据不足,想追加搜索?做不到。
- 一个模型干所有事。 搜索需要创意、验证需要严谨,没法区分。
所以后来彻底重构成了 LangGraph 的 StateGraph。
现在的管道
我把整个研究拆成了这些节点,每个节点干一件事:
几个有意思的节点:
意图分析:把模糊问题变具体。 用户经常问得很笼统,比如"AI 对就业的影响"。这个节点把它拆成可搜索的子问题:AI 替代了哪些岗位?创造了哪些新职业?各国政府怎么应对?拆完之后用向量相似度去重------防止"AI 替代岗位"和"AI 导致失业"这种同义拆解浪费搜索次数。
向量去重用的是阿里云的通义 Embedding Vision Flash,256 维,中文语义理解很稳,跟 DeepSeek 统一在 DashScope 网关下接入。超过 0.85 相似度的判定为同义重复,整个去重逻辑不超过二十行。
两级过滤,先快后慢。 粗过滤基于规则(去重 URL、去低质域名),能砍掉六七成噪音。LLM 精过滤再砍两三成。最终进验证环节的通常只有原始结果的 20% 左右,但信息密度高得多。
循环:验证不过关就回头搜。 验证子图跑完之后,如果存在矛盾维度且未达最大轮数(3 轮),管道自动回到搜索节点,针对矛盾点追加搜索。
状态持久化:关了浏览器也没事
LangGraph 的 Checkpointer 会在每个节点执行后把整个 ResearchState 序列化存到 PostgreSQL:
lua
ResearchState
├── context → 身份信息(谁、哪个租户、哪个预设)
├── control → 控制参数(速度档位、模式)
├── memory → 中间记忆(历史消息、已证事实、摘要)
├── runtime → 运行时数据(搜索缓存、管道状态)
└── output → 最终产出(报告、声明列表、置信度)
这意味着:用户提问后关了浏览器,过十分钟再打开,研究进度还在。Worker 继续跑,前端重新连上 SSE 就能看到中间结果。这个体验说实话挺爽的------第一次实现了"无感断线"。
四、我给 AI 搭了个法庭
这是整个系统最核心的模块。我决定不信任任何单次 LLM 输出,而是:从多个信源提取事实,对比它们的一致性,给出置信度评分。
这个模块叫"审判室",四步流程:
第一步:Atomize------拆成"一句话事实"
把几千字的新闻稿拆成原子声明。比如这篇 Neuralink 报道:
"Neuralink 于 2024 年 1 月宣布完成首例人体脑机接口植入手术,受试者为一名因脊髓损伤而四肢瘫痪的患者..."
拆成:
css
声明 1:Neuralink 于 2024年1月完成首例人体植入 [primary]
声明 2:受试者是脊髓损伤导致的四肢瘫痪患者 [secondary]
声明 3:手术由斯坦福大学医学中心执行 [secondary]
声明 4:受试者术后出现感染症状 [primary][争议性声明]
每条声明标记重要级别,记录来自哪个信源。拆解由 LLM 来做------因为同一事实在不同文章里的表述可能完全不同。
这里用的 LLM 是 DeepSeek 。为什么不是 GPT-4o?性价比------DeepSeek 价格大概是 GPT-4o 的 1/10,中文能力不输甚至更好,API 完全兼容 OpenAI SDK。一键切 base_url 就行。但英文信源推理确实不如 GPT-4o,所以我留了个开关:用户可以在 Preset 里给不同阶段绑定不同模型,验证阶段用更强模型,搜索阶段用便宜的。
第二步:Profile------信源画像
有了声明列表,接下来判断信源本身靠不靠谱:
| 评分维度 | 看什么 |
|---|---|
| 内容质量 (0~1) | 信息密度、逻辑是否严密、有没有数据支撑 |
| 营销倾向 (0~1) | 是不是软文、有没有商业推广意图 |
| 专家引用 (0~1) | 有没有引用权威机构或专家 |
一个来自《自然》杂志的报道,内容质量可能 0.9,营销倾向 0.1。一个营销号的文章,内容质量可能 0.2,营销倾向 0.9。这些分数直接影响后面的裁决权重。
第三步:Tripartite------三方共识
对每一条声明,从所有信源中找相关证据,判断一致性:
比如"受试者术后出现感染"------如果 3 个不同信源都报道了且细节吻合,就是 Consistent。如果一个说"感染"、一个说"无异常"、一个说"轻微不适",就是 Contradictory。
第四步:Arbitrate------最终裁决
综合所有声明的验证结果和信源权重,给出裁决:
- Supported(证实):多源一致支持
- Contradicted(证伪):多源一致否定
- Unverifiable(无法核实):现有信源不足以判断
全局置信度分五级:verified → likely_true → disputed → uncertain → unverifiable。
灵感来源
说真的,这个四步流程没多高深,就是照着法庭审判的套路来的:
| 法庭里的角色 | Verify Subgraph 里的对应 |
|---|---|
| 收集证据 | Atomize:把复杂信息拆成原子事实 |
| 评估证人可信度 | Profile:评估每个信源的质量 |
| 交叉质证 | Tripartite:让不同来源对同一个事实"对质" |
| 法官裁决 | Arbitrate:综合证据和权重做最终判断 |
我不是第一个拿法庭模型做信息验证的,但你别说,每次跟别人解释这个模块,一说"我给 AI 搭了个法庭",对方表情立马从"你说啥"变成"哦~懂了"。
核验子图的实现:LangGraph Subgraph
整个核验流程作为独立的 LangGraph Subgraph 嵌套在主管道里。Subgraph 的好处:
- 状态隔离。 内部状态不污染主图
- 可独立测试。 可以单独跑核验子图,喂搜索结果看裁决质量
- 可替换。 换一套验证逻辑只需要写新 Subgraph,主管道一行不改
已知不足: 信源画像用的是通用 LLM,没有针对中文信源权威性做专门微调。"内容质量"打分有时偏高------有些 AI 摘要网站也被打高分,但其实是二手信息。裁决逻辑目前是加权平均,没有考虑信源之间的独立性问题------两个媒体可能引用了同一个原始采访,但系统当成两个独立信源来计票。
五、三条队列,别再堵了
一开始只有一条队
最早版本特别天真,就一条 Redis 队列,谁先来谁先走。结果快速问答经常排在深度研究屁股后面,得等三四分钟。朋友试用完直接问我:"你是不是写了个 Bug?查个天气要等三分钟?"
我赶紧去看日志,一看就乐了------深度研究在前面吭哧吭哧跑两三分钟,后面堵了七八个快速问答。这体验,就像超市只开一个收银台,你买瓶水得等前面大妈结完一整车的年货。
三条队 + 权重
拆成三条队列,加权轮询:
| 队列 | 对应模式 | 权重 | 含义 |
|---|---|---|---|
ts:queue:fast |
极速快问 | 4 | 每 7 次被消费 4 次 |
ts:queue:expert |
专家搜索 | 2 | 每 7 次 2 次 |
ts:queue:pipeline |
深度研究 | 1 | 每 7 次 1 次 |
调度序列就是:fast ×4 → expert ×2 → pipeline ×1 → 循环。每次 BRPOP timeout 0.1s,一个循环总共 0.7 秒。即使在深度研究的高负载下,快速问答最多等零点几秒。
那为啥不干脆给快速队列最高优先级呢?因为你想想,如果快速队列只要有人排队就打死不处理深度队列,那深度研究可能一整天都排不上------这叫"饿死"。加权轮询的好处是每种任务都能被照顾到,只是频率不一样。而且你仔细品:选了深层研究的用户,本来心里就知道"这玩意儿得等几分钟",多等一小会儿完全能接受。
并发控制
一开始我啥限制都没加,觉得自己写的是异步代码嘛,怕啥。结果 LangGraph 的图执行本身也是异步的,十几个协程同时抢事件循环,Worker CPU 直接飙到 90%。后来老老实实加了个 asyncio.Semaphore(2),单 Worker 最多同时跑 2 个任务。简单粗暴,但从此 CPU 就乖了。
Auto-Scaler
Worker 内有个独立协程,根据三个队列的总深度调整轮询频率:少于 2 个任务休眠 5 秒省 CPU,超过 10 个任务休眠 1 秒快速消费。三段 if-else,没什么黑科技,但有效。
为什么用 ARQ 做 Worker
ARQ 是 FastAPI 作者 Samuel Colvin 开发的异步任务队列库,跟 FastAPI 和 Pydantic 同一人出品,生态兼容性天然好。对比 Celery:
| 维度 | ARQ | Celery |
|---|---|---|
| 异步模型 | 原生 asyncio | prefork/thread pool |
| 配置复杂度 | 1 个 Worker 函数 + 1 行启动 | 多文件配置 |
选它的理由跟 FastAPI 一致:够用且轻量。POC 阶段不需要 Celery 的复杂功能。
已知不足: 当前只有单 Worker,没法水平扩展。加权轮询的权重是拍脑袋定的(4:2:1),没有基于实际负载数据调优。取消信号依赖 Redis PubSub,但 PubSub 不保证送达------网络抖动时可能丢掉取消指令。
六、用户的 API Key 存在我这,我比他还怕泄露
说实话,做这个系统最让我睡不着觉的事,就是用户的 API Key。人家的 DeepSeek Key、搜索引擎 Key 都填在我这儿了,这要是漏了,我拿什么赔?
数据隔离
所有 SQL 查询都带两个条件:WHERE tenant_id = ? AND user_id = ?。tenant_id 和 user_id 从 JWT 里提取,API 中间件注入到请求上下文,不是靠前端传参------后端解析 Token 绑定的,没法伪造。
API Key 加密:存进去的是乱码
用 Fernet 加密(AES-128-CBC + HMAC-SHA256 签名):
bash
明文 API Key → AES 加密 → HMAC 签名 → base64 编码 → 存库
读取时 → base64 解码 → 验证 HMAC(防篡改)→ AES 解密 → 明文使用
加了 HMAC 意味着:即使有人黑了数据库、改了密文,解密时会因为签名对不上而直接报错。这不止是加密,这是防篡改。
加密密钥可以从 JWT 密钥派生,也可以独立设置环境变量,方便以后轮换。
SSRF 防护:Worker 不能访问内网
Worker 在跑研究时会去请求外部 URL。如果有人提交恶意 URL 指向 http://169.254.169.254/metadata(云服务器元数据接口),Worker 如果傻傻去请求,等于把服务器敏感信息送出去了。
我的防护是 DNS 级别:解析 URL 的所有 IP,逐个检查是否为私有地址(127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16)。还留了个特殊放行:198.18.0.0/15------Clash 代理用的保留网段,不放行的话 Worker 根本访问不了外部 API。
密码存储
PBKDF2-SHA256 哈希,48 万轮迭代。验证密码时用 hmac.compare_digest() 做常量时间比较------不管你输的密码对不对,比较时间一样长,防止旁路攻击。
身份认证
支持两种登录:传统用户名密码 + Logto OIDC。OIDC 接入不复杂:后端从 Logto 的 JWKS 端点拿公钥,校验 RS256 签名。两种方式并存------我自己用密码登录省事,给别人演示时用 OIDC 显得正规。
已知不足: SSRF 防护是 DNS 级别的,有绕过可能(DNS Rebinding、HTTP 重定向到内网)。生产级应该用代理隔离。日志里目前没有脱敏,有些错误日志可能把解密后的 API Key 打印出来------这个必须修。多租户隔离靠 WHERE 条件,没有做 PG 原生 Row-Level Security,万一有查询忘了带 WHERE 条件就跨租户泄露了。
七、十分钟加一个搜索引擎的重构
起初我写得很糙。博查、Tavily、知乎三个引擎各写一套,到处散落着 if-else。想加个新的?得把四五个文件翻一遍。
python
# 最初的丑代码大概长这样
if engine == "bocha":
results = await bocha_search(query)
elif engine == "tavily":
results = await tavily_search(query)
elif engine == "zhihu":
results = await zhihu_search(query)
后来想加 Google Search 的时候终于受不了了------调度逻辑跟引擎逻辑搅成一锅粥,改调度影响引擎,改引擎又影响调度。咬咬牙:拆。
插件系统:三个组件
插件注册中心 (Registry): 全局字典,所有插件通过装饰器自注册,不用手动维护列表。
搜索编排器 (Orchestrator): 拿到启用的插件列表,asyncio.gather 并发调用,最后跨引擎去重。
插件基类 (SearchPlugin): 所有引擎必须实现的抽象类,就三个方法:
python
class SearchPlugin(ABC):
@property
def name(self) -> str:
"""引擎名称"""
@property
def is_reader(self) -> bool:
"""是否内容读取插件"""
return False
async def search(self, query, api_key, **kwargs):
"""执行搜索,返回统一格式"""
加一个新引擎,三步
- 写插件类,加
@plugin_registry.register()装饰器 - 在
VALID_SEARCH_ENGINES里加一行名字 - UI 配置页面配 API Key
编排器和去重逻辑一行不用改。
python
# 并发调度的核心就这一行
results = await asyncio.gather(
*[plugin.search(query, api_key) for plugin in active_plugins],
return_exceptions=True # 某个引擎报错不影响其他
)
return_exceptions=True 是关键------某个搜索引擎超时了,异常被包装成 Exception 对象放在结果列表里,不会影响其他引擎已返回的结果。
| 重构前 | 重构后 |
|---|---|
| 加引擎改 4-5 个文件 | 加引擎改 2 个文件 |
| 调度逻辑和引擎耦合 | 调度和引擎独立,各自测试 |
| 引擎报错影响全局 | 单引擎故障不阻塞 |
| 搜索结果重复 | 跨引擎 URL 去重统一处理 |
已知不足: 插件系统只接了三个引擎,没做过不同搜索引擎结果质量的 A/B 对比------博查的中文搜索比 Tavily 好吗?不知道。去重目前只靠 URL 精确匹配 + 标题相似度,两个不同 URL 的网页可能互相抄袭同一篇文章,这种内容级去重还没做。
八、别让用户盯着白屏
第一版做完的时候我自己试了一下,差点把自己气死。点"开始研究"→ 界面死了 → 过了三五分钟 → 啪,糊一脸结果。
中间那几分钟,用户就盯一个转圈发呆。不知道系统挂没挂、AI 在忙啥、还剩多久。我自己用了一次就想骂人。
从轮询到 SSE
一开始图省事,没搞 SSE。就返回个 task_id,让前端每两秒轮询一次查进度。结果呢?百分之九十的请求都是白打的,而且进度是跳着走的,卡一下突然蹦一截。
这才老老实实上了 SSE:
Worker 执行 LangGraph 图
→ 每个节点产生事件
→ 发布到 Redis PubSub
→ API 进程订阅 PubSub
→ 格式化为 SSE 推给浏览器
事件类型
| 事件 | 什么时候发 | 前端干嘛 |
|---|---|---|
step |
进入新管道阶段 | 更新进度条和思考链面板 |
model |
LLM 流式输出 Token | 追加消息(打字机效果) |
complete |
研究完成 | 展示报告和声明验证卡片 |
error |
任何节点报错 | 显示错误提示 |
sync |
断线重连时 | 恢复完整状态 |
最大的坑:断线重连
然后我就踩了整篇文章最大的一个坑:Redis PubSub 不存历史。浏览器一断线,掉线期间的事件全部蒸发了。你关了标签页重新打开,就看见进度条像个鬼一样从 0% 直接跳到 80%,中间发生了什么?不知道。这体验比白屏还诡异。
后来想了个招------双通道:
- Redis PubSub:正常连接时用,零延迟实时推送
- Redis Stream:当"历史缓冲区"用,断线重连时补全丢失的事件
重连流程:前端调 /api/v1/chat/resume → API 从 Redis Stream 读历史事件 → 推送重建完整状态 → 继续从 PubSub 消费新事件。
Nginx 必须配的几个配置
nginx
proxy_buffering off; # 关缓冲,确保事件即时推送
proxy_read_timeout 86400s; # 长连接超时 24h
proxy_cache off; # 禁用缓存
第一次没加 proxy_buffering off,前端收到的事件是"攒一波再推"------每隔 30 秒刷一大段,完全没有实时感。排查了半天才发现是 Nginx 默认开了代理缓冲。
已知不足: SSE 断线重连还没大规模压测。事件解析器目前靠一堆 if-else 匹配,随着 LangGraph 版本升级事件格式可能变。最理想的是把 Parser 做成可配置的事件映射表。
九、一行命令跑起来
我对部署的要求很简单:随便谁 git clone 下来,敲一个命令,所有东西跑起来。不需要装 Python、不需要装 Node、不需要装 PG。Docker 就够了。
对外只暴露 80 端口,剩下全是容器间内部通信。
为什么不用 K8s?
一个人运维,Kubernetes 太重了。编写 Deployment、Service、Ingress、ConfigMap、Secret 就得半天。Docker Compose 一条 docker compose up -d 搞定。但这个选择有代价: 没有健康检查自动重启、没有滚动更新、没有资源限制细粒度控制。POC 阶段能忍,有真实用户后必须补。
Nginx 的两个坑
坑一:默认 60 秒超时。 深度研究可能跑几分钟,但 Nginx 的 proxy_read_timeout 默认 60 秒。结果研究跑了一分钟多连接断了,前端收不到后续事件。修复:proxy_read_timeout 86400s。
坑二:代理缓冲导致事件延迟。 proxy_buffering off 和 proxy_cache off 必须加。
数据库迁移时机
应用服务启动前必须先跑完数据库迁移。Docker Compose 用 depends_on + healthcheck 解决:
yaml
postgres:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U truthseeker"]
interval: 5s
backend:
depends_on:
postgres:
condition: service_healthy
Makefile:给自己省时间
部署命令记不住,所以写了个 Makefile:
makefile
deploy:
docker compose up --build -d
down:
docker compose down
logs:
docker compose logs -f
clean:
docker compose down -v # ⚠️ 删数据
make clean 尤其危险------加 -v 会删掉所有数据卷。我自己踩过这个坑,想"清理一下"结果把测试数据全清掉了。
已知不足: 就单机 Docker Compose,没有容器编排、监控告警、日志聚合、灰度发布、自动备份。SSL 依赖外部 Cloudflare。健康检查只有一个
/health端点。这些都是 POC 阶段故意欠的债------要加的话开发时间得翻倍。
结尾:不完美的诚实交代
前后断断续续写了几个月,目前跑在一台云服务器上,日常处理几十次研究请求。TDD 写了 30 多个测试文件,配了 Logto OIDC 登录,搭了前后端全异步链路。
全部已知不足汇总
- 验证环节: 信源权重没有考虑独立性,通用 LLM 打分偏高,没有针对性的评估数据集
- 管道: 条件路由阈值是拍脑袋硬编码,节点串行执行未做并行优化
- 调度: 单 Worker 无法水平扩展,权重未经数据调优,取消信号不保证送达
- 安全: SSRF 是 DNS 级别可绕过,日志未脱敏,多租户未用 PG 原生 RLS
- 搜索插件: 未做引擎质量 A/B 对比,内容级去重缺失
- SSE: 断线重连未大规模压测,Parser 硬编码不够健壮
- 部署: 单机无监控告警无备份,无滚动更新策略
这些都是我明知道该做、但 POC 阶段来不及做的。写出来不是示弱,是诚实------一个人做全栈本身就到处妥协,关键是你得知道自己在妥协什么。
下一步:往 Agent 方向深钻
现在管道里的 Agent 还比较"规矩"------能搜、能读、能推理,但始终在一个预设的流程里跑。我感兴趣的方向:
- 多 Agent 各自负责一个研究维度,然后互相 review
- Agent 自己的长期记忆,不只是 LangGraph 的 thread checkpoint,而是跨会话的经验积累
- Agent 发现自己缺工具时,能不能自己写代码造一个
找工作优先,但这几个方向会陆续写出来------不会再停在 tutorial 级别。
这个项目也是一个 vibe coding 的尝试。以前拿到需求就开写,现在习惯先用 AI 聊清楚产品设计、用 AI 过需求方案,把自己的角色从"闷头写代码"慢慢转变成了"想清楚再写、写完再审"。代码量没以前大了,但想的时间多了不少。
对 AI 工程化感兴趣的话,欢迎聊聊。
- 📞
18251886173 - ✉️
18251886173@163.com - GitHub 源码