我给 AI 搭了个法庭:一个前端仔的 LangGraph 实战全记录

我用 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 首例人体植入后受试者出现感染,真的假的?"它会:

  1. 把你的问题拆成几个子问题:植入时间、受试者状态、感染报告来源
  2. 同时去多个搜索引擎搜(博查、Tavily、知乎)
  3. 把所有搜到的网页里的事实拆成"原子声明"------比如"手术于 2024 年 1 月完成"
  4. 对每条声明,检查有多少来源在说同样的事,这些来源靠谱吗
  5. 最终给你一份报告:哪些事实被多个权威信源证实,哪些只有一个来源在传,哪些来源之间互相矛盾
flowchart LR A[用户提问] --> B[意图分析<br/>拆解子问题] B --> C[多引擎并发搜索] C --> D[结果过滤<br/>去重+去营销号] D --> E[原子声明提取] E --> F[跨源交叉验证] F --> G[做出裁决] G --> H[生成报告]

核心跟普通搜索的区别在于那个交叉验证环节------我后来管它叫"审判室"。

四个档位,丰俭由人

不是所有问题都需要这么重的流程,所以做了四个档位:

模式 适合场景 耗时
极速快问 简单确认,比如查一个价格 几秒
专家搜索 深入了解一个主题 几十秒
深度研究 复杂问题、需要多源验证 几分钟
智能模式 让 AI 自己判断该用哪个 自适应

这个设计思路跟知乎直答 Agent 的三档模式很像------都是从"秒回"到"深度挖掘"的分层策略。但 TruthSeeker 比它多了一个关键环节:交叉验证。不是搜完就直接回答,而是让不同信源"对质"之后再下结论。


二、技术选型的酸甜苦辣

我一个写了七八年 JavaScript 的人,突然要搭一个 Python 后端的 AI 项目,说实话一开始是有点抗拒的。但 LLM 生态最好的工具链全在 Python 侧------LangChain、LangGraph、FastAPI------这是现实,没什么好纠结的。

整体架构

系统大概分三块:用户交互的前端、处理请求的后端、以及真正干活的 Worker 进程。为什么要把后端和 Worker 拆开?因为深度研究要跑几分钟,不能让 HTTP 请求一直挂着等------后端收到请求就丢进队列,Worker 在后台慢慢跑,结果通过 SSE 实时推回去

graph TB Browser[浏览器] <-->|Next.js| Frontend[前端 :3000] Frontend <-->|API / SSE| Backend[FastAPI 后端 :8000] Backend --> PG[(PostgreSQL<br/>业务数据)] Backend --> Redis[(Redis<br/>缓存/队列/SSE)] Worker[ARQ Worker<br/>后台任务执行] --> Redis Worker --> PG Worker --> Search[搜索引擎插件<br/>博查/Tavily/知乎] Worker --> LLM[大模型<br/>DeepSeek/通义千问]

FastAPI vs Django:选轻的,别选全的

Django 太重了。这个项目需要的是:路由层、中间件、异步支持、SSE 流式响应。不需要 ORM 自带的后台管理、不需要模板引擎、不需要表单验证------这些我前端都自己做了。

FastAPI 的好处:原生 async/await、Pydantic 请求校验、Swagger UI 自动生成、启动快。但后悔的是: FastAPI 的依赖注入系统一开始用得很爽,项目大了以后到处 Depends() 让调用链很难追踪。如果有下次,我会把业务逻辑更多放在 service 层,路由只做参数校验和转发。

LangGraph vs 纯 LangChain:状态机才是正道

刚开始我用的是 LangChain 的 LLMChain,就是最简单的"给一个 Prompt,拿一个回答"。很快发现两个问题:

  1. 流程不可控。研究需要多步走(意图分析 → 搜索 → 验证 → 报告),不是一次 LLM 调用能搞定的。用 Chain 串联虽然能做,但中间状态断了就全丢了。
  2. 需要循环。验证发现信源矛盾,得回头重新搜索。这种"条件路由+循环"在 LangChain 里写起来很痛苦。

LangGraph 解决的就是这两个问题:它把整个流程定义成一个状态机,每个节点是独立的处理步骤,节点之间可以条件跳转,而且每一步的状态自动持久化------等于自带断点续传。

stateDiagram-v2 [*] --> 策略规划 策略规划 --> 意图分析: 深度研究模式 策略规划 --> Agent快速路径: 快问/搜索模式 意图分析 --> 搜索规划 搜索规划 --> 粗过滤 粗过滤 --> 精过滤 精过滤 --> 核验子图 state 核验子图 { [*] --> 提取声明 提取声明 --> 信源画像 信源画像 --> 三方共识 三方共识 --> 裁决 } 核验子图 --> 存在冲突: 有矛盾且未达上限 存在冲突 --> 搜索规划: 补充搜索 核验子图 --> 无冲突: 验证完成 无冲突 --> 报告生成 报告生成 --> [*]

当时没对比的: 应该对比 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、用户的模型列表、用户的研究策略------这三样东西不能混在一起存:

graph LR subgraph 凭证层 P[Provider<br/>API Key 加密] end subgraph 资产层 A[ModelAsset<br/>模型注册] end subgraph 策略层 S[Preset<br/>阶段-模型绑定] end P -->|提供 API 能力| A A -->|被 Preset 引用| S S -->|决定管线行为| Graph[LangGraph Pipeline]

比如:用户配了 DeepSeek 的 API Key(凭证层),注册了 deepseek-chatdeepseek-reasoner 两个模型(资产层),然后创建一个 Preset 说"意图分析用 chat 模型、验证用 reasoner 模型"(策略层)。三层解耦后,换 API Key 不需要重配策略,加新模型也不需要改预设。


三、把研究拆成 6 个工人

最初的版本极其朴素:用户提问 → 搜索 → 把搜索结果喂给 LLM → 生成回答。十几个 LLMChain 串起来,跑得动,但问题一大堆:

  1. 中间结果丢了。 验证到一半报错------你得从头再跑。
  2. 没法回头。 验证发现某个维度证据不足,想追加搜索?做不到。
  3. 一个模型干所有事。 搜索需要创意、验证需要严谨,没法区分。

所以后来彻底重构成了 LangGraph 的 StateGraph

现在的管道

我把整个研究拆成了这些节点,每个节点干一件事:

flowchart TD START[用户提问] --> strategy[策略规划<br/>判断用哪个模式] strategy -->|快问/搜索| agent[Agent 节点<br/>自主搜索+直接回答] agent --> END strategy -->|深度研究| intent[意图分析<br/>拆解子问题] intent --> search[搜索规划<br/>生成多组关键词] search --> coarse[粗过滤<br/>去重+去低质信源] coarse --> fine[LLM 精过滤<br/>评估相关性] fine --> verify[核验子图<br/>交叉验证] verify -->|有矛盾<br/>未达上限| search verify -->|通过| report[报告生成] report --> summary[总结节点] summary --> END[完成]

几个有意思的节点:

意图分析:把模糊问题变具体。 用户经常问得很笼统,比如"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 输出,而是:从多个信源提取事实,对比它们的一致性,给出置信度评分

这个模块叫"审判室",四步流程:

flowchart TD Input[过滤后的搜索结果] --> A[Atomize<br/>原子声明提取] A --> B[Profile<br/>信源画像] B --> C[Tripartite<br/>三方共识] C --> D[Arbitrate<br/>最终裁决] D --> Output[置信度评分 + 裁决结果]

第一步: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------三方共识

对每一条声明,从所有信源中找相关证据,判断一致性:

flowchart LR Claim[一条声明] --> Search[从所有信源<br/>检索相关证据] Search --> Compare{对比结果} Compare -->|2+信源一致| Consistent[一致证实] Compare -->|大体一致<br/>误差无法交叉验证] Compare -->|无证据| Unverifiable[无法核实]

比如"受试者术后出现感染"------如果 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 的好处:

  1. 状态隔离。 内部状态不污染主图
  2. 可独立测试。 可以单独跑核验子图,喂搜索结果看裁决质量
  3. 可替换。 换一套验证逻辑只需要写新 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_iduser_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/810.0.0.0/8172.16.0.0/12192.168.0.0/16169.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 的时候终于受不了了------调度逻辑跟引擎逻辑搅成一锅粥,改调度影响引擎,改引擎又影响调度。咬咬牙:拆。

插件系统:三个组件

flowchart TB O[Orchestrator<br/>调度编排器] --> R[Registry<br/>插件注册中心] R --> B[Bocha Plugin] R --> T[Tavily Plugin] R --> Z[Zhihu Plugin] R --> M[MyEngine Plugin<br/>你的新引擎] O -->|asyncio.gather<br/>并发调用| Plugins[已注册的搜索插件] O -->|跨引擎去重| Dedup[去重逻辑]

插件注册中心 (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):
        """执行搜索,返回统一格式"""

加一个新引擎,三步

  1. 写插件类,加 @plugin_registry.register() 装饰器
  2. VALID_SEARCH_ENGINES 里加一行名字
  3. 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 推给浏览器
sequenceDiagram participant W as Worker participant R as Redis PubSub participant A as API 进程 participant B as 浏览器 W->>W: 图节点执行... W->>R: 发布事件 (step/model/error) R->>A: 推送事件 A->>B: SSE: data: {...} W->>W: 研究完成 W->>R: 发布 complete 事件 R->>A: 推送完成事件 A->>B: SSE: event: complete

事件类型

事件 什么时候发 前端干嘛
step 进入新管道阶段 更新进度条和思考链面板
model LLM 流式输出 Token 追加消息(打字机效果)
complete 研究完成 展示报告和声明验证卡片
error 任何节点报错 显示错误提示
sync 断线重连时 恢复完整状态

最大的坑:断线重连

然后我就踩了整篇文章最大的一个坑:Redis PubSub 不存历史。浏览器一断线,掉线期间的事件全部蒸发了。你关了标签页重新打开,就看见进度条像个鬼一样从 0% 直接跳到 80%,中间发生了什么?不知道。这体验比白屏还诡异。

后来想了个招------双通道:

sequenceDiagram participant W as Worker participant PS as Redis PubSub participant ST as Redis Stream participant A as API 进程 participant B as 浏览器 Note over W: 正常推送 W->>PS: 发布实时事件 W->>ST: 写入历史缓冲 PS->>A: 消费 A->>B: SSE 实时推送 Note over B: 断开后重连 B->>A: POST /resume A->>ST: 读取历史事件 ST->>A: 返回缓冲事件 A->>B: 恢复状态 + 继续订阅 PubSub
  • 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 就够了。

graph TB N[Nginx :80<br/>统一入口] F[Frontend :3000<br/>Next.js] B[Backend :8000<br/>FastAPI] W[Worker<br/>ARQ 后台执行] P[(PostgreSQL 16)] R[(Redis 7)] N -->|/*| F N -->|/api/*| B N -->|/api/v1/chat<br/>SSE长连接| B W --> R W --> P B --> P B --> R

对外只暴露 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 offproxy_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 工程化感兴趣的话,欢迎聊聊。

相关推荐
JensCS猿1 小时前
从 Spring Boot 回看 SSM 框架:手动挡与自动挡的驾驶哲学
后端
爱勇宝1 小时前
干了近 8 年,一夜之间被裁:AI 时代,程序员最该害怕的不是 AI
前端·后端·程序员
科米米1 小时前
嵌入式日志模块
后端
Pedantic1 小时前
Combine 框架学习笔记
前端
runnerdancer2 小时前
Agent如何加载执行Skill的脚本
前端·agent
血小溅2 小时前
三大 AI 编码框架深度对比:GSD vs OpenSpec vs Superpowers
人工智能·后端
yingyima2 小时前
VS Code 正则替换技巧:从凌晨3点的服务器报警开始
前端
ThanksGive2 小时前
层级时间轮看门狗
后端
默_笙2 小时前
🛬 我让 AI 帮我写了一个打飞机游戏,结果 Canvas 把我整不会了
前端·javascript