Agent 与编排

Article / Agent 与编排

用 LangGraph 组织 RAG 工作流

从 CitedRAG 的五节点图出发,讲清状态、预算、中断恢复,以及多 Agent 框架的选型逻辑。

CitedRAGNovaCode LangGraphRAGHITL状态图

一个 RAG demo 可能只是一个函数:检索、拼提示词、生成。但当系统需要判断问题难易、拆解研究任务、在证据不足时拒答、在高成本步骤之前请求人工确认,隐式的流程就会变成不断膨胀的 if/else。CitedRAG 用 LangGraph 把流程显式建模成状态图,用预算和审批约束模型的自由发挥。

RAG 流程与状态图

RAG(Retrieval-Augmented Generation,检索增强生成)先用检索找到与问题相关的资料,再让模型只依据资料作答。把它写成一个函数很容易,难的是系统在真实运行时的分支:简单问题应该一次检索直答,复杂问题要拆成多步研究,检索不到证据要拒答而不是硬答,高成本计划还应该允许人介入。函数里的 if/else 会随着这些需求不断膨胀,而且每一步的中间状态都藏在局部变量里,既不可观测也不可恢复。

图的心智模型完全不同:节点是能力单元,边是控制流,状态是一张显式的表。每一步的输入输出都是状态的一个切片,于是「一步步跑」「跑到一半停」「停下来再继续」成为运行时能力,而不是靠业务代码自己拼接。LangGraph 的核心抽象就是这张状态图,CitedRAG 的 backend/src/agent/ 用它实现了整条问答流水线,而编排的设计原则是:能用启发式与确定性规则决定的,不花 LLM 调用;只有规则覆盖不了的分支才交给模型,并且每个角色都有硬性预算与失败回退。角色分工也是同样的逻辑:direct 只做一次检索加一次生成,planner 只负责拆任务,researcher 只负责找证据,writer 只负责成稿。每个节点的提示词只描述一件事,比把所有要求塞进一个巨型提示词有效得多。

五个节点与共享状态

图的骨架很小,五个节点加一组条件边:

builder = StateGraph(AgentState)
builder.add_node("supervisor", supervisor_node)
builder.add_node("direct", direct_node)
builder.add_node("planner", planner_node)
builder.add_node("researcher", researcher_node)
builder.add_node("writer", writer_node)
builder.add_edge(START, "supervisor")

supervisor 用 Command(goto=...) 动态路由到四个 worker 之一;每个 worker 走条件边回到 supervisor 或 END,终止条件有三个:已有最终回答、决策为 finish、迭代达到上限 8。

AgentState 是共享状态表,两个约定尤其重要。消息用 add_messages reducer 累积,保证多节点写入不会互相覆盖;研究计划 research_tasks 是结构化列表而不是自然语言,任务 id、检索词、状态、证据 id 都显式存在,后续节点直接读,不需要解析消息反推计划。回调(token 流、取消、证据推送)在有 checkpoint 时通过运行配置传递而不是写进持久状态,凭证同理——状态表里只放可以安全重放的数据。

answer() 是给 API 层的高层接口:没有 thread_id 时编译一张无 checkpoint 的图;带了 thread_id(就是 run_id)就挂上 SQLite checkpointer 并启用人工审批。checkpoint 保存每个节点执行后的完整状态,随时可以从中继续。返回结果包含回答、引用、工具追踪、委派路径、迭代次数、研究任务、用量与状态,运行结束还会追加 checkpoint 恢复、LLM 用量、节点耗时三类运行时追踪。

四个 worker 的分工明确。direct 执行一次检索加一次生成,简单问题不进入多步流程;planner 把问题拆成最多三个结构化任务,解析优先级是 JSON、编号列表,最后退化为单任务,解析失败也记录 warning 而不是中断;researcher 默认直接用 planner 给出的检索词并行检索,多任务在共享线程池里完成、按 chunk id 去重合并,只有显式的 ReAct 模式才让模型逐轮选工具——评测显示默认模式的平均检索调用数只有 ReAct 的六分之一;writer 对全部证据再做一次低阈值重排,按预算精选后成稿,引用编号与证据严格同序。

决策优先级与预算约束

supervisor 的决策有严格优先级,LLM 排最后。direct 拒答或留下「结论被移除」的警告时,先把当前回答存为兜底,升级到 planner 做多步研究;如果 planner 路径最终仍然拒答,而 direct 有过带引用的部分回答,就回退到那份部分回答——比一个更慢的拒答更有用。之后是两条确定性规则:有未完成任务就派 researcher,任务都已终结但有证据就派 writer。只有这些规则都给出不了答案,才调用 LLM 路由。

LLM 路由本身也做了防御:提示词要求严格输出 ROLE: <role>,代码用全匹配正则解析。不用 structured output 的原因很实际——DeepSeek 端点不支持 response_format,强行依赖它会让路由在特定 provider 上直接失效。解析失败时按状态安全回退并记录 warning,而不是让流程悬空。这里还踩过两个真实的坑:supervisor 的历史如果只带 System 加一条旧 AIMessage,模型会返回空响应,所以每轮必须追加一条明确的 HumanMessage;而截断历史会破坏 tool_calls 与 tool 消息的配对,严格网关直接返回 400,所以传给 supervisor 的历史只保留 human 与普通 AI 消息。这些都是真实 LLM 才会触发的 bug,mock 测试给不了任何提示。

预算分三层,互相独立。迭代预算限制 supervisor 的派发轮数;researcher 持有工具预算,控制检索次数、读取页数与证据条数,每次工具调用前先预留,超预算直接返回提示而不是继续消耗 token;运行级预算约束整次运行的 LLM 调用总数,超出的只记软告警,不打断已经接近完成的回答。并发的 researcher 任务也有限流:进程级上限默认 3,低内存机器自适应降到 1,瞬时故障只重试一次,失败时 worker 降级为结构化拒答加 warning,而不是把异常抛成 500。direct 的拒答检测也做得很具体:除了固定的拒答文案,模型自己措辞的拒答会被识别并补一条 answer_incomplete 警告,触发 supervisor 升级到多跳路径——「模型没找到证据」和「模型草草收场」会导向两种不同的处理。

任务状态由证据质量决定:检索结果经重排后最高分达到 0.40 记为完成,有结果但不相关记为证据不足,异常记为失败。writer 拿到「证据不足」的任务时,提示词要求明确说明缺口,但仍要回答有证据支持的部分。把这些状态写进共享状态而不是藏在节点内部,是让整个流程可观测、可评测的关键——委派路径、任务数与耗时都能直接从运行记录里读出来。

人工审批与中断恢复

高成本计划在执行任何检索工具之前会暂停:待执行任务数达到 3,或估算的 LLM 调用数超过 8,且请求带 checkpoint 时,supervisor 调用 LangGraph 的 interrupt() 挂起图执行。前端收到 approval_required 事件,弹出审批卡片:可以批准、编辑任务列表(最多保留 6 条并逐条校验),或取消。取消不产生任何模型与工具调用,直接返回拒答并附 plan_rejected 警告。批准后写回「计划已批准」再继续;检索与生成等高成本步骤都在人工审批之后才执行。

中断依赖 checkpoint,这也决定了恢复语义:图挂起时会持久化完整状态;恢复时 supervisor 节点从头重放,这一次 interrupt() 返回审批结果,而 planner 的任务来自 checkpoint,不会重新生成。API 层用 POST /runs/{run_id}/approval 触发续跑。这里有一个刻意的取舍:provider 配置与凭证不落库,恢复时按当前默认配置重新解析——状态里只有可以安全重放的数据。

这套机制的价值不只在人工审批。SSE(Server-Sent Events,服务端通过普通 HTTP 连接持续把事件推给浏览器的轻量协议)流在 approval_required 处正常收尾,运行状态落库为等待审批,前端刷新页面后仍能从运行记录里恢复现场;服务端重启后,带 checkpoint 的失败运行会被标记为可恢复,由 resume 路径继续。任何一次运行都可以追溯到当时的计划、证据与委派路径。

LangChain 与 LangGraph 的定位差异

这两个名字经常被混着说,定位其实不同。LangChain 是一套组件与集成库:模型封装、提示词模板、检索器、工具、输出解析器,解决「接什么、用什么」,把各家模型和向量库的差异磨平。LangGraph 是编排运行时:把工作流建模为状态图,提供节点、边、状态通道、持久化、中断与恢复,解决「流程怎么走、状态放在哪、跑到一半怎么办」。一个是能力库,一个是控制平面。

两者同源但不绑定:LangGraph 建立在 LangChain 生态的基础原语之上,用它并不要求把所有组件都换成 LangChain 实现。CitedRAG 的用法就是混合的——图与状态交给 LangGraph,模型调用、检索、提示词按项目自己的结构组织。LangGraph 提供的是三件普通代码难以自己实现的东西:可持久化的执行状态、图级别的中断恢复、以及带 reducer 语义的共享状态。依赖关系也值得说清:两者共享同一套消息与运行时原语,生态里的回调与追踪工具可以通用。对已经在用 LangChain 组件的项目,它是平滑的升级路径;从零开始的项目也可以只取状态图这一层。

多 Agent 框架的对比与选型

主流框架的定位差异比较清楚。AutoGen 从多 Agent 对话发展而来,擅长让多个角色通过消息互相协商,适合探索型和辩论型协作;CrewAI 用「角色加任务」的团队隐喻做声明式定义,上手最快,适合业务流程化的固定协作;OpenAI Agents SDK 是官方轻量方案,围绕 agent 循环、handoff 与 guardrails 展开,与 OpenAI 生态贴合最紧;Semantic Kernel 面向企业集成,插件与流程编排可以同既有系统平滑衔接;DSPy 则完全不关心运行时编排,它把提示词与模型调用抽象成可用指标编译优化的模块,解决的是「怎么把质量调上去」,而不是「流程怎么管」。

选型时,几个维度比框架名气更重要:流程形态是固定流水线还是开放式循环;是否必须支持持久化与中断恢复;流式粒度需要多细,前端要不要交互式确认;状态与副作用能否安全重放;以及依赖与学习成本、可观测性与测试的代价。选择框架就是接受它的约束、换取它替你管理状态,判断标准是这些约束是否与核心需求一致。

CitedRAG 选 LangGraph,是因为 RAG 编排的诉求恰好是图擅长的:流程可预测、节点可中断、运行可恢复、委派路径可观测。而 NovaCode 手写 ReAct 循环,是因为它的核心诉求在另一侧:token 级流式事件、模型还在输出时就执行工具、携带 future 的权限确认、三种前端共享同一契约、以及 Hook 与 Skill 这类扩展面——这些都需要对循环内部有完全的控制粒度,通用图运行时反而会带来额外限制。选型取决于约束是否匹配。还有一个常被忽略的维度:框架替你管理的状态,也正是你会失去直接控制的部分。把 checkpoint 交给框架,就要接受它的序列化边界与恢复语义;手写循环,就要自己保证取消、恢复与一致性。快速验证阶段,声明式框架的起步成本最低;当交互形态与成本模型变成核心竞争力时,控制粒度会重新变得重要。