LangGraph实战指南:前端AI法庭搭建全记录

2026-06-28阅读 0热度 0
ai

我用 LangGraph 从零搭了个"反谣言"搜索引擎

今年年初,知乎开放了他们的直答 Agent API。这事儿简单来说就是,你抛一个问题,它自己去搜网页、读内容、整合信息,最后甩给你一份带来源引用的回答。你不用自己搭知识库,也不用折腾 RAG 管线——搜索引擎就是现成的"数据库",找几个 API Key 一配就能跑。

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

它提供了三档模式,设计思路挺清晰:

模式干什么的耗时
简单模式快速直答,适合"今天天气"这种问题秒级
深度模式基于知乎知识库的深度分析几十秒
DeepSearch实时多引擎搜索 + 多源整合 + 综合分析几分钟

说白了,这就是个搜索类的 Agent。

LangChain 算是学了点皮毛,LangGraph 的文档翻了三天愣是没太看懂,Agent 这个概念也是模模糊糊的。但你要说直接上手写一个?我觉得可以试试。倒不是因为我技术多牛,而是搜索类 Agent 有个天然优势——数据这块省心。不用做知识库,就不用纠结文档切分和 Embedding 质量;不做 RAG,就不用反复调 chunk size 和检索策略;不微调模型,就不用折腾数据集。数据全在搜索引擎里,你要干的活儿就是:搜索 → 读取内容 → 分析 → 回答。这条链路清晰得像流水线一样。

对于想学 AI Agent 的新手来说,这确实是个很好的切入点——API 接入简单、链路看得见摸得着、效果也是立竿见影。于是,我就动手了。

背景:AI 把岗位卷没了,我选择主动拥抱它

2026 年 2 月,公司通知我"毕业"了。说好听点叫优化,说难听点,就是被 AI 卷掉的。写了七八年的 React,组件树倒背如流,Webpack 配置闭着眼睛都能调。但今年再看招聘市场,纯前端岗位缩水了不止一半。简历投出去,回复越来越像 ChatGPT 写的——"你很优秀,但目前我们更需要具备 AI 工程化能力的全栈工程师"。

焦虑是真的焦虑。身边不少同行在抱怨"AI 抢饭碗",但与其抱怨,不如动手学。以前是 AI 替代我,那我能不能反过来利用它?把它当工具,用它做出以前一个人根本搞不定的东西?

还没缓过劲儿,家里的事情就一件接一件。索性在家待着,一边处理家事,一边把一直想搞的 AI 方向认真地补了补。说是"补",其实就是硬啃。以前脑子里全是 React 组件树和状态管理,突然要去理解 Embedding、RAG、Agent 循环、状态机编排这些概念,坦白说中间卡了好多次。对着 LangGraph 的文档看了三天没看明白,最后是边抄代码边跑,才慢慢摸索出点门道。

这个名为 TruthSeeker 的项目,就是在家里鼓捣出来的。一个用 LangGraph 搭建的深度研究引擎,前端、后端、模型调用、部署,全是自己一个人搞定。说实话过程挺狼狈的——经常半夜对着屏幕上一堆报错信息发呆。但每解决一个问题,那种"原来是这样"的感觉,比写一百个 React 组件都来得爽。

这个项目有一个贯穿始终的硬约束:预算为零,Token 太贵了。没有公司报销 API 费用,用的都是自己充值的 DeepSeek 余额。深度研究模式跑一轮,意图分析加上多引擎搜索、原子声明提取、信源画像、三方共识、最终裁决,加起来可能要调用十几次 LLM。如果用 GPT-4o,一次深度研究的推理成本可能得好几块钱。一个月跑几百次,工作还没找到,人先破产了。

所以如果你看到后文各种"为什么不那样做"的决策,别觉得奇怪——它们背后只有一个统一的原因:穷。为什么选 DeepSeek?便宜啊。为什么搞两级过滤?少喂 Token 给 LLM,能省一分是一分。为什么最多只搜 3 轮?多一轮都是真金白银。为什么把模型切成"搜索用便宜的、验证用强的"?好钢当然要用在刀刃上。这篇文章里几乎所有技术决策,都可以归结为一句话:穷有穷的做法。

我并不觉得这是"转行"。前端工程师的核心能力从来不是写 JSX,而是理解用户需求、设计交互逻辑、具备工程化思维。这些能力换个语言、换个领域一样能用。LangGraph 的状态机跟 Redux 本质上是一回事,管道的条件路由跟 React Router 也差不多。真正的壁垒从来不是语言,而是你是否愿意从头开始。

一、TruthSeeker 到底做什么?

先回答最直接的问题:这玩意儿能干嘛?

简单说,你提一个问题,它去全网搜,然后把不同来源的说法摆在一起对比,告诉你哪些是真的、哪些是矛盾的、哪些根本没法验证。不追求"给你一个答案",而是追求"告诉你这个答案可信吗"。

举个例子,如果你问:"Neuralink 首例人体植入后受试者出现感染,真的假的?"它会:

  1. 把你的问题拆成几个子问题:植入时间、受试者状态、感染报告来源
  2. 同时去多个搜索引擎搜(博查、Ta vily、知乎)
  3. 把所有搜到的网页里的事实拆成"原子声明"——比如"手术于 2024 年 1 月完成"
  4. 对每条声明,检查有多少来源在说同样的事,这些来源靠不靠谱
  5. 最终给你一份报告:哪些事实被多个权威信源证实,哪些只有一个来源在传,哪些来源之间互相矛盾

flowchart LR A[用户提问] --> B[意图分析
拆解子问题] B --> C[多引擎并发搜索] C --> D[结果过滤
去重+去营销号] D --> E[原子声明提取] E --> F[跨源交叉验证] F --> G[做出裁决] G --> H[生成报告]

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

四个档位,丰俭由人

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

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

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

二、技术选型的酸甜苦辣

一个写了七八年 Ja vaScript 的人,突然要搭一个 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
业务数据)] Backend --> Redis[(Redis
缓存/队列/SSE)] Worker[ARQ Worker
后台任务执行] --> Redis Worker --> PG Worker --> Search[搜索引擎插件
博查/Ta vily/知乎] Worker --> LLM[大模型
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 实时推送进度给 APIPubSub 延迟接近零
缓存LLM 重复请求缓存内存读写比 PG 快两个数量级

用 Redis 扛三个角色最大的好处是运维一致——Docker Compose 里只多一个服务。代价是 Redis PubSub 不保证消息送达(断线就丢消息),后面会考虑用 Redis Stream 来补救。

三层配置模型:想了最久的设计

用户的 API Key、用户的模型列表、用户的研究策略——这三样东西不能混在一起存:

graph LR subgraph 凭证层 P[Provider
API Key 加密] end subgraph 资产层 A[ModelAsset
模型注册] end subgraph 策略层 S[Preset
阶段-模型绑定] 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[策略规划
判断用哪个模式] strategy -->|快问/搜索| agent[Agent 节点
自主搜索+直接回答] agent --> END strategy -->|深度研究| intent[意图分析
拆解子问题] intent --> search[搜索规划
生成多组关键词] search --> coarse[粗过滤
去重+去低质信源] coarse --> fine[LLM 精过滤
评估相关性] fine --> verify[核验子图
交叉验证] verify -->|有矛盾
未达上限| 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:

ResearchState ├── context → 身份信息(谁、哪个租户、哪个预设) ├── control → 控制参数(速度档位、模式) ├── memory→ 中间记忆(历史消息、已证事实、摘要) ├── runtime → 运行时数据(搜索缓存、管道状态) └── output→ 最终产出(报告、声明列表、置信度)

这意味着:用户提问后关了浏览器,过十分钟再打开,研究进度还在。Worker 继续跑,前端重新连上 SSE 就能看到中间结果。这种"无感断线"的体验,说实话还是挺爽的。

四、我给 AI 搭了个法庭

这是整个系统最核心的模块。核心思路是:不信任任何单次 LLM 输出,而是从多个信源提取事实,对比它们的一致性,给出置信度评分。

这个模块叫"审判室",分四步走:

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

第一步:Atomize——拆成"一句话事实"

把几千字的新闻稿拆成原子声明。比如一篇 Neuralink 的报道:

会拆成:

声明 1:Neuralink 于 20241月完成首例人体植入 [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[从所有信源
检索相关证据] Search --> Compare{对比结果} Compare -->|2+信源一致| Consistent[一致证实] Compare -->|大体一致
误差无法交叉验证 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,主管道一行不改。

五、三条队列,别再堵了

一开始只有一条队

最早版本特别天真,就一条 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:

维度ARQCelery
异步模型原生 asyncioprefork/thread pool
配置复杂度1 个 Worker 函数 + 1 行启动多文件配置

选它的理由跟 FastAPI 一致:够用且轻量。POC 阶段不需要 Celery 的复杂功能。

六、用户的 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 签名):

明文 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 显得正规些。

七、十分钟加一个搜索引擎的重构

起初写法很糙。博查、Ta vily、知乎三个引擎各写一套,代码里到处散落着 if-else。想加个新的?得把四五个文件翻一遍。

# 最初的丑代码大概长这样 if engine == "bocha": results = await bocha_search(query) elif engine == "ta vily": results = await ta vily_search(query) elif engine == "zhihu": results = await zhihu_search(query)

后来想加 Google Search 的时候终于受不了了——调度逻辑跟引擎逻辑搅成一锅粥,改调度影响引擎,改引擎又影响调度。咬咬牙,拆。

插件系统:三个组件

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

插件注册中心 (Registry): 全局字典,所有插件通过装饰器自注册,不用手动维护列表。

搜索编排器 (Orchestrator): 拿到启用的插件列表,asyncio.gather 并发调用,最后跨引擎去重。

插件基类 (SearchPlugin): 所有引擎必须实现的抽象类,就三个方法:

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

编排器和去重逻辑一行不用改。

# 并发调度的核心就这一行 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 去重统一处理

八、别让用户盯着白屏

第一版做完的时候自己试了一下,差点把自己气死。点"开始研究"→ 界面卡住 → 过了三五分钟 → 啪一下,甩一脸结果。中间那几分钟,用户就只能盯着一个转圈发呆。系统到底挂没挂?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进入新管道阶段更新进度条和思考链面板
modelLLM 流式输出 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 必须配的几个配置

proxy_buffering off; # 关缓冲,确保事件即时推送 proxy_read_timeout 86400s; # 长连接超时 24h proxy_cache off; # 禁用缓存

第一次没加 proxy_buffering off,前端收到的事件是"攒一波再推"——每隔 30 秒刷一大段,完全没有实时感。排查了半天才发现是 Nginx 默认开了缓冲。

九、一行命令跑起来

对部署的要求很简单:随便谁 git clone 下来,敲一个命令,所有东西跑起来。不需要装 Python、不需要装 Node、不需要装 PG。Docker 就够了。

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

postgres: healthcheck: test: ["CMD-SHELL", "pg_isready -U truthseeker"] interval: 5s backend: depends_on: postgres: condition: service_healthy

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 会删掉所有数据卷。自己就踩过这个坑,想"清理一下"结果把测试数据全清掉了。

结尾:不完美的诚实交代

前后断断续续写了几个月,目前跑在一台云服务器上,日常处理几十次研究请求。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 级别。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策