基于LangGraph的自我路径规划与结果验证Agent实践 1. 为什么单步 Agent 一定会在真实任务里翻车我最早做 Agent 的时候也走过弯路把用户的问题塞进一个足够长的 prompt让模型自己想一想再回答最多加几个工具调用就觉得这已经是个智能体了。Demo 阶段确实好看一旦任务变成查三份资料、做一次交叉比对、按固定结构写出来这种多步活儿翻车率高得离谱。后来我才想明白问题不在模型不够强而在于我把规划和验收这两件本该分开做的事情全压进了同一次生成里。这篇内容就围绕基于 LangGraph 实现一个具备自我路径规划及结果验证的 Agent展开把我从零搭这套东西的过程、状态怎么设计、验证器怎么校准、死循环怎么掐断尽量完整地写下来。先说清楚这个 Agent 是什么、能干什么。它的核心特征是两件事一是自我路径规划接到任务后先产出一份可执行的子目标清单而不是直接开始写答案二是结果验证每完成一步或整体收尾前由一个独立的评审环节检查产出是否达标、是否引用了真实存在的依据、是否真的回答了原问题。不达标就分两种情况处理——局部重试或者整体重新规划。适合谁来参考我判断是三类人已经会用大模型 API 但被多步任务稳定性折磨的开发者正在评估图编排框架、想知道状态机式 Agent 到底比链式调用强在哪的技术负责人以及想把某条业务流程自动化、但卡在AI 输出不可信这一关的业务侧同学。哪怕你暂时不写代码第 2 章的状态设计思路和第 5 章的排查表也能直接拿去对着改。1.1 一次性生成的三个硬伤我总结下来把规划和执行揉在一次生成里会稳定地暴露三个问题而且这三个问题不是靠换个更强的模型能解决的。第一个硬伤是中间态不可见。模型在一次生成里想了什么、跳过了哪一步、为什么选了这个工具你全都看不到。出了问题只能靠反复调 prompt 猜这跟调试一个没有任何日志的服务没区别。第二个硬伤是错误无法局部修正。一个五步任务做到第四步发现第二步的数据取错了单次生成没有回到第二步这个概念它只能在第五步硬圆圆出来的东西往往比直接报错更危险。第三个硬伤是自我评价失真。让同一个上下文里刚写完答案的模型评价自己它天然倾向于说没问题。这不是模型撒谎而是它的上下文中已经充满了自己刚才的推理链评价变成了自我一致性检查而不是对外部标准的检查。把这三件事映射到架构上答案就很明确了规划要抽成独立节点中间态要落进结构化状态里验证要放在一个看不到执行过程自我辩解的独立环节。LangGraph 的图模型刚好把这三点都接住了。1.2 自我路径规划到底在规划什么这里有个容易误解的点。很多人以为路径规划就是让模型说一句我打算先做 A 再做 B输出一段自然语言的计划。这种计划对后续执行没有任何约束力等于没规划。我理解的自我路径规划是产出结构化的、带验收标准的、可被程序消费的子目标清单。举个具体的对比。松散版计划是这样的1. 收集相关信息2. 分析3. 得出结论。 这种计划你拿它去驱动执行节点执行节点会自由发挥验证节点也没法判断分析到底算不算完成。结构版计划长这样[ {id: s1, goal: 从给定的三份材料中抽出所有带时间戳的事实, accept: 事实条目数 3且每条带来源标记, tool_hint: search}, {id: s2, goal: 按时间顺序排列并标出互相矛盾的条目, accept: 矛盾项被显式列出或明确说明无矛盾, tool_hint: null} ]差别在哪每个子目标都带accept字段这就是给验证节点看的验收标准。规划节点和验证节点共享同一套契约验证才有依据。我在实际项目里发现只要逼着模型把验收标准写出来它拆步骤的质量会明显提升——因为它必须先想清楚这一步做成什么算做完才写得下 accept。这算是个白捡的收益。1.3 结果验证为什么必须做成独立节点验证节点的价值不在于多一道检查而在于它引入了一个没有立场分歧的失败信号。Agent 系统最怕的不是失败是不知道自己失败了一路自信地跑到底输出一堆似是而非的东西。我把验证拆成三层这三层的松紧程度完全不同混在一起做就会失控层级检查内容实现方式误判倾向结构校验输出格式、必填字段、枚举值纯代码JSON 解析 schema 比对几乎无误判但只能抓低级错误依据校验每个事实断言是否能在证据池里找到对应条目正则抓引用标记 集合比对会误杀改写过的引用需要容错目标校验是否覆盖全部子目标、是否回答了原任务模型评审 打分容易偏松需要用坏样本校准第二层的做法我强烈建议你抄要求执行节点在产出里给每一个事实性断言挂上引用标记形如[E3]指向证据池里某条证据的 ID。验证节点用正则把\[E\d\]全抓出来跟证据池的真实 ID 集合做差集。差集非空说明模型编了引用直接判不通过。这一招抓到幻觉的效率比让模型自己检查有没有编造高一个数量级因为它是确定性的。1.4 选 LangGraph 的四个具体理由框架选型这块我纠结过最后落到 LangGraph 上有四个很实际的原因不是为了赶热度。第一它把控制流显式化了。节点是函数边是声明条件路由是一个返回字符串的函数。这意味着什么情况下重新规划什么时候结束是可以被读出来的代码而不是藏在 prompt 里的约定。第二状态是核心抽象。所有节点围绕一个共享状态读写你可以随时把状态打印出来看轨迹调试体验跟看分布式追踪差不多。第三循环是原生支持的。链条式编排做循环要自己套 while状态管理很容易乱图编排里回边就是普通边只是要注意别失控。第四检查点机制。配上内存或持久化检查点你可以按thread_id恢复对话、在执行中断点插入人工审核、甚至回放某次失败运行的完整轨迹。人工介入这点在业务场景里是刚需没有它很多流程根本不敢上线。注意循环是双刃剑。图里一旦出现回边就必须同时配好三样东西——循环计数器、重规划上限、框架层的递归上限。少配一个都可能跑飞。2. 架构落地把 Agent 拆成规划器、执行器、评审器整体结构我用了四个节点加一个终结节点planner负责拆子目标executor负责干活verifier负责验收finalize负责汇总成最终产出。它们之间不是单向链而是带两条回边的图验证不通过且问题局部回到executor重试当前子目标验证不通过且问题在规划本身回到planner重规划。这一节把状态、职责、路由三件事讲透。2.1 状态字段设计与语义约定状态是整个系统的地基。我踩过的第一个坑就是状态字段随手加加到后面分不清哪个字段是当前轮的、哪个是累计的验证逻辑全乱。所以字段必须带明确语义约定。字段类型语义生命周期taskstr原始任务全程只读永久planlist[dict]当前生效的子目标清单重规划时整体替换cursorint当前处理到第几个子目标每完成一个 1resultsdict已完成的子目标 ID 到产出的映射只增不减evidencelist证据池带 ID 与摘要累加需瘦身fail_reasonslist失败记录供重试与重规划参考累加需去重dead_endsset已判定走不通的计划指纹累加用于拒绝重复规划roundsint总循环次数单调递增replansint重规划次数单调递增verdictdict最近一次验证结论每次验证覆盖关键约定有两条。一是只增字段用于回滚results和evidence只增不减重规划时不清空。原因很实在——重规划最怕把前面已经做对的部分推翻重来白白烧钱。二是覆盖字段用于信号verdict每次验证整体替换它表达的是此刻的判断不是历史。这两类字段混用是 bug 高发区我在早期版本里就是因为verdict用追加语义导致路由函数读到了上一轮的旧结论出现了验证说不通过但路由走了通过分支这种鬼故事。2.2 节点职责边界职责边界不清是第二个大坑。我最初的executor里顺手加了如果发现计划有问题就自己调整一下的逻辑结果planner和executor互相打脸轨迹乱成一团。后来的原则是每个节点只做一件事只允许通过状态字段影响别的节点不许直接改别人的成果。planner的输入是taskfail_reasonsdead_ends输出是新的plan并重置cursor。它不许碰results。executor只处理plan[cursor]这一个子目标输出写进results[子目标ID]必要时往evidence追加证据。它不许改plan就算它觉得计划很蠢也不许改只能在产出里写一句该步骤信息不足让验证节点去判。verifier只读task、plan、results、evidence输出verdict里面包含passed、score、layer哪一层没过、reason、scope局部还是全局。finalize只负责把results按plan顺序拼装成最终文本并做一次终检。这个边界最大的好处是故障可定位。轨迹跑歪了你看verdict.layer就知道是格式问题、依据问题还是目标问题然后直接定位到对应的 node。边界糊在一起的时候你只能靠肉眼读一长串日志。2.3 条件边的路由规则与终止条件路由函数是这套系统里最该小心翼翼写的地方它决定了 Agent 会不会跑飞。我的路由逻辑按优先级从高到低排rounds max_rounds→ 强制走finalize带降级标记明确告诉用户这是未完全验证的结果。replans max_replans且验证未通过 → 走finalize同样带降级标记。verdict.passed True且cursor len(plan) - 1→ 全部完成走finalize。verdict.passed True且还有剩余子目标 →cursor 1回executor处理下一个。verdict.passed False且scope local→ 回executor重试当前子目标。verdict.passed False且scope global→ 回planner重规划。兜底verdict缺失或格式异常 → 走finalize降级。第 1 条和第 2 条必须放在最前面这是兜底优先原则。很多跑飞的案例都是因为把完成判断写在前面而完成条件又依赖一个可能永远不满足的验证结果于是循环永远不退出。第 7 条也别省验证器输出格式异常是真实存在的情况尤其当你换了模型或者调了 prompt 之后。我现在的习惯是路由函数的每个分支都打一行日志带上rounds、replans、cursor、score出问题看这几行就够了。2.4 循环预算的分配预算这事必须提前想不能等它烧钱。我用的默认值是max_rounds 12、max_replans 3、单次执行工具调用上限 5 次。这组数字不是拍脑袋来的是按最坏情况倒推的。假设任务是 4 个子目标理想路径是 4 次执行 1 次终检 5 轮留出 2 到 3 次局部重试的余量就是 8 轮左右。再留一次重规划重规划后子目标可能变成 5 个需要重新走一遍大概 4 轮加起来 12 轮是个比较舒服的上限。max_replans 3是因为经验上第三次重规划之后基本就是在原地打转了——dead_ends里已经攒了三四个走不通的方向模型很难再想出真正新的路子继续下去纯属浪费。工具调用上限 5 次是防止某个子目标里陷入搜了不满意再搜的循环超过就让它带着现有材料交付交给验证节点判。提示max_rounds一定要小于框架层的递归上限否则你自定义的降级逻辑永远不会被执行到框架会先抛异常。把递归上限设成max_rounds 5这种留余量的值让自定义逻辑先接管。3. 完整代码实现从空文件到能跑通的图这一章全是可直接抄的东西。我把代码按节点拆开讲最后组装。模型客户端我做了层封装叫什么名字不重要你换成自己常用的就行——关键不在模型在状态流转和验证契约。为了代码可读模型调用统一走llm.chat()和llm.json()两个方法前者返回文本后者按给定 schema 返回结构化对象。3.1 依赖与目录结构pip install langgraph目录我习惯这样分节点一个文件、契约一个文件、图组装一个文件后面加东西不打结agent/ state.py # 状态定义 contracts.py # 结构化输出的 schema nodes/ planner.py executor.py verifier.py finalize.py graph.py # 组装与编译 tools.py # 工具注册表依赖上只装langgraph就够了检查点用内置的内存版够测试用。生产环境把检查点换成持久化实现别的不用动。3.2 状态定义与结构化输出契约状态用TypedDict定义。这里有个 LangGraph 的细节要留意如果一个字段是列表并且你希望多个节点往里追加而不是覆盖要用Annotated[list, operator.add]声明归约方式。不声明的话后写的节点会把前面的覆盖掉这是新手最容易踩的坑之一。# state.py import operator from typing import Annotated, TypedDict, Any class AgentState(TypedDict): task: str plan: list[dict] cursor: int results: dict[str, str] evidence: Annotated[list[dict], operator.add] fail_reasons: Annotated[list[str], operator.add] dead_ends: list[str] rounds: int replans: int verdict: dict[str, Any]契约文件里定义两类结构规划输出和验证输出。这两个 schema 是整个系统的接口写清楚了后面全好办。# contracts.py PLAN_SCHEMA { type: object, properties: { steps: { type: array, items: { type: object, properties: { id: {type: string}, goal: {type: string}, accept: {type: string}, tool_hint: {type: [string, null]}, }, required: [id, goal, accept], }, } }, required: [steps], } VERDICT_SCHEMA { type: object, properties: { passed: {type: boolean}, score: {type: number}, layer: {type: string, enum: [structure, evidence, goal]}, reason: {type: string}, scope: {type: string, enum: [local, global]}, }, required: [passed, score, layer, reason, scope], }layer和scope这两个字段是刻意设计的。layer让排查有的放矢scope让路由有依据。很多人把验证结果只做成一个布尔值结果路由只能靠猜这是自找麻烦。3.3 规划节点的实现细节规划节点的 prompt 我改过十几版最后稳定下来的写法有几个要点明确禁止自然语言计划、强制每个步骤带验收标准、把dead_ends和fail_reasons打进 prompt、要求步骤数控制在 2 到 6 之间。# nodes/planner.py import hashlib, json from contracts import PLAN_SCHEMA PLANNER_PROMPT 你是任务规划器。把任务拆成 2-6 个可独立验证的子目标。 硬性要求 1. 每个子目标必须写清 accept即可被第三方检查的完成标准禁止使用合理适当等模糊词。 2. 子目标之间尽量串行如果必须并行请在 goal 里注明依赖关系。 3. 下面列出的失败方向绝对不要再提 {dead_ends} 4. 上一轮的失败原因规划时必须规避 {fail_reasons} 任务{task} def fingerprint(steps: list[dict]) - str: key |.join(sorted(s[goal].strip() for s in steps)) return hashlib.md5(key.encode()).hexdigest()[:12] def planner_node(state, llm): prompt PLANNER_PROMPT.format( taskstate[task], dead_ends\n.join(state[dead_ends]) or 无, fail_reasons\n.join(state[fail_reasons][-5:]) or 无, ) out llm.json(prompt, schemaPLAN_SCHEMA) steps out[steps] fp fingerprint(steps) if fp in state[dead_ends]: # 计划撞车强制追加约束再试一次 out llm.json(prompt f\n\n你刚给出的方案已被判定走不通指纹 {fp}必须换思路。, schemaPLAN_SCHEMA) steps out[steps] fp fingerprint(steps) return { plan: steps, cursor: 0, dead_ends: state[dead_ends] [fp] if state[replans] 0 else state[dead_ends], replans: state[replans] 1 if state[replans] 0 or state[rounds] 0 else 0, rounds: state[rounds] 1, }这里有三个值得单独说的设计。计划指纹用子目标文本排序后哈希目的是识别换个说法但其实是同一个计划的情况。模型重规划时特别喜欢把收集资料改成检索相关信息看着不一样实际一模一样的死路。指纹能抓住这个。撞车重试只重试一次不无限重试因为两次都给不出新方案说明要么任务本身不可行要么 prompt 缺信息再试也是浪费。dead_ends只在重规划时追加首轮规划不记录否则首轮的计划指纹会被当成已失败后面永远撞车。3.4 执行节点与工具调用重试执行节点只关心一件事把plan[cursor]做出来并且把用到的依据登记进证据池。它拿到失败原因时要带着原因重做这一点很多人漏掉——重试不带原因等于让模型重掷骰子。# nodes/executor.py from tools import TOOLS EXEC_PROMPT 完成下面这一个子目标不要做别的事。 子目标{goal} 验收标准{accept} 可用工具{tools} 上一轮失败原因若有必须针对性修正{reason} 产出要求 1. 每个事实性断言后面必须挂引用标记形如 [E1]指向你实际调用工具拿到的证据。 2. 不许编造证据编号没有依据的断言宁可删掉。 3. 完成后用一句话说明你的产出如何满足验收标准。 def executor_node(state, llm): step state[plan][state[cursor]] reason state[fail_reasons][-1] if state[fail_reasons] else 无 local_evidence [] transcript for _ in range(5): out llm.chat(EXEC_PROMPT.format( goalstep[goal], acceptstep[accept], toolslist(TOOLS.keys()), reasonreason) transcript) call parse_tool_call(out) if not call: break result TOOLS[call[name]](**call[args]) eid fE{len(state[evidence]) len(local_evidence) 1} local_evidence.append({id: eid, q: call[args], digest: result[:400]}) transcript f\n\n[已调用 {call[name]}结果编号 {eid}]{result[:800]} final llm.chat(EXEC_PROMPT.format( goalstep[goal], acceptstep[accept], toolslist(TOOLS.keys()), reasonreason) transcript \n\n现在给出最终产出必须带引用标记。) return { results: {**state[results], step[id]: final}, evidence: local_evidence, rounds: state[rounds] 1, }digest只存前 400 字是有意的。证据池会随着轮次膨胀全量存原文很快就把上下文撑爆。验证节点的依据校验只需要这条证据大概讲什么不需要全文。全文放外部存储验证时按 ID 取回摘要比对即可。这个取舍我在第 4 章会展开。3.5 验证节点的三层校验验证是整个系统的价值核心我把它写成三个函数逐层过任何一层不过就短路返回避免后面白算。# nodes/verifier.py import re from contracts import VERDICT_SCHEMA CITE_RE re.compile(r\[E(\d)\]) def check_structure(text: str, step: dict) - tuple[bool, str]: if len(text.strip()) 20: return False, 产出过短无法判断是否完成子目标 cites CITE_RE.findall(text) if not cites: return False, 产出中没有任何证据引用标记 return True, ok def check_evidence(text: str, pool: list[dict]) - tuple[bool, str]: cited {fE{c} for c in CITE_RE.findall(text)} real {e[id] for e in pool} fake cited - real if fake: return False, f引用了不存在的证据编号{sorted(fake)} return True, ok def check_goal(task, step, text, llm) - tuple[bool, float, str]: prompt f只做评审不要重写答案。 原任务{task} 当前子目标{step[goal]} 验收标准{step[accept]} 产出 {text} 独立判断产出是否满足验收标准。注意 - 只看产出本身是否达标不要因为产出努力过就放宽。 - 若达标给 score 0.8部分达标给 0.5-0.79明显不达标给 0.5。 v llm.json(prompt, schemaVERDICT_SCHEMA) return v[passed], v[score], v[reason]三层之间是有顺序逻辑的。结构和依据在前目标在后因为前两层是确定性的、几乎零成本能挡掉相当一部分问题。我实测过光靠必须有引用标记和引用必须真实存在这两条就能拦下大约四成的幻觉产出而且完全不花模型调用的钱。等到进第三层的时候剩下的样本质量已经好很多模型评审的准确率也上来了。第三层的 prompt 有两个刻意的处理。一是只做评审不要重写答案这句必须有不然模型会顺手帮你把答案改了你就分不清它是在评判还是在改作业。二是明确给了分数区间语义0.8和0.5两个阈值对应后面的路由判断不给区间模型就会给你一堆 0.7、0.75 这种模棱两可的分路由直接瘫痪。组装的验证节点把三层串起来并做分数融合def verifier_node(state, llm): step state[plan][state[cursor]] text state[results].get(step[id], ) ok, reason check_structure(text, step) if not ok: return {verdict: {passed: False, score: 0.0, layer: structure, reason: reason, scope: local}, fail_reasons: [reason], rounds: state[rounds] 1} ok, reason check_evidence(text, state[evidence]) if not ok: return {verdict: {passed: False, score: 0.2, layer: evidence, reason: reason, scope: local}, fail_reasons: [reason], rounds: state[rounds] 1} passed, score, reason check_goal(state[task], step, text, llm) scope local if score 0.5 else global return {verdict: {passed: passed, score: score, layer: goal, reason: reason, scope: scope}, fail_reasons: [] if passed else [reason], rounds: state[rounds] 1}scope的划分逻辑是关键分数在 0.5 到 0.8 之间说明子目标本身是合理的只是这次没做好局部重试低于 0.5说明这个子目标可能压根就定错了需要回到规划层。阈值不是玄学是可以用样本校准的第 4 章讲具体方法。3.6 路由函数与图组装路由函数严格按 2.3 节的优先级写。图组装注意回边要指向正确的节点终结边指向END。# graph.py from langgraph.graph import StateGraph, END from langgraph.checkpoint.memory import MemorySaver from state import AgentState from nodes.planner import planner_node from nodes.executor import executor_node from nodes.verifier import verifier_node from nodes.finalize import finalize_node MAX_ROUNDS, MAX_REPLANS 12, 3 def route(state) - str: if state[rounds] MAX_ROUNDS: return finalize if state[replans] MAX_REPLANS and not state[verdict].get(passed): return finalize v state[verdict] if v.get(passed): if state[cursor] len(state[plan]) - 1: return finalize return next_step return retry if v.get(scope) local else replan def bump_cursor(state): return {cursor: state[cursor] 1} def build(llm): g StateGraph(AgentState) g.add_node(planner, lambda s: planner_node(s, llm)) g.add_node(executor, lambda s: executor_node(s, llm)) g.add_node(verifier, lambda s: verifier_node(s, llm)) g.add_node(finalize, lambda s: finalize_node(s, llm)) g.add_node(bump, bump_cursor) g.set_entry_point(planner) g.add_edge(planner, executor) g.add_edge(executor, verifier) g.add_conditional_edges(verifier, route, { next_step: bump, retry: executor, replan: planner, finalize: finalize, }) g.add_edge(bump, executor) g.add_edge(finalize, END) return g.compile(checkpointerMemorySaver(), interrupt_before[finalize])bump单独做成一个节点是因为executor和verifier都不该改cursor——前者只做当前步骤后者只判当前步骤。让第三个节点专门做推进指针这个动作职责干净。interrupt_before[finalize]是我默认加的因为绝大多数业务场景都需要在最终产出前看一眼确认没跑偏。不需要的话去掉就行。跑起来cfg {configurable: {thread_id: run-001}, recursion_limit: 17} result build(llm).invoke({task: ..., plan: [], cursor: 0, results: {}, evidence: [], fail_reasons: [], dead_ends: [], rounds: 0, replans: 0, verdict: {}}, cfg)3.7 轨迹观察一次真实的运行长什么样跑通之后我第一件事是打轨迹不打轨迹等于盲飞。我用stream模式把每个节点的输入输出摘要打出来格式大概是round3 | nodeverifier | score0.62 | layergoal | scopelocal | reason缺少第二份材料的时间戳对比。这种一行一条的轨迹扫一眼就知道卡在哪。有次我拿到一条典型轨迹第 1 轮规划出 3 个子目标第 1 个子目标一次过第 2 个子目标连续两次 score 都是 0.55 左右重试两次后触发了scope升级回规划层新计划把原来的第 2 个子目标拆成了两个更细的步骤一次就过了。这条轨迹说明阈值设置是有效的——如果我把scope阈值设成 0.6那次就会一直局部重试直到烧完预算。反过来如果设成 0.4它会过早重规划把本来能救的步骤推翻。阈值这东西必须用真实轨迹调。4. 调参和踩坑让这套结构真正稳定结构搭起来只完成了三成剩下七成是调。这一章全是血泪每条我都付过代价。4.1 验证器松紧度校准验证器最常见的两种病太松什么都放行等于没有太紧把好产出也毙了导致无限重试直到预算耗尽。校准方法我说个可复现的笨办法比凭感觉调靠谱得多。先准备两组样本。正样本你自己手工写的、明确达标的产出8 到 10 条。负样本故意做的差的包括格式错了的、引用了不存在证据的、答非所问的、内容空洞的也是 8 到 10 条。负样本要覆盖三种失效模式别全是格式错误。然后拿验证器跑这两组统计两个指标正样本通过率、负样本拦截率。理想情况是正样本 9/10 以上通过负样本 10/10 全拦。如果不达标怎么调正样本被误杀说明check_goal的 prompt 太严或者验收标准写得太苛刻先在 prompt 里加一句允许表述风格差异只判内容是否覆盖还不行就检查accept字段是不是要求过细。负样本被放过多半是第三层评审太松办法是给评审批次加必须指出至少一个具体问题的要求——注意是指出不是编造prompt 里要写清楚若无问题请明确说无问题。这个技巧有点反直觉但实测有效逼模型去找问题它会启动更细致的检查不逼它它就倾向给个中庸分。注意校准用同一批样本反复调很容易过拟合到这批样本上。我的做法是留三条正样本和三条负样本完全不动只在最后验收时跑一次。前面调的时候看不见它们心里才有底。4.2 上下文瘦身与证据池管理证据池是最容易爆的地方。一个跑十几轮的任务每轮调两三次工具证据条目轻松上三十条每条几百字光证据就吃掉上万 token。而且这还没算上results里堆积的历史产出。我的处理是三级瘦身。第一级证据只存摘要执行节点写入时就截断到 400 字全量原文扔外部存储用 ID 关联。第二级验证时按需取只有被引用的证据 ID 才去外部存储拉全量没被引用的不拉。第三级历史结果折叠results超过 5 条时把最早的两条用一次模型调用压成 100 字小结标记为已压缩。第三级要慎用压缩会丢细节我只在长任务上开。还有个细节值得说证据 ID 必须全局单调递增不能每个子目标从 E1 重新编。一旦重号依据校验的集合比对就失效了两条不同的证据共用一个 ID模型引用哪一个都算存在幻觉就漏过去了。我现在的做法是用fE{len(state[evidence]) 本地计数 1}第 3.4 节的代码里就是这么写的。4.3 重规划的去重与失败记忆重规划是这套系统里最贵的动作贵在两个地方模型调用成本以及推翻了已经做对的部分的隐性成本。我踩过最惨的一次是重规划后cursor归零前面三个已经通过的子目标全部重跑多花了四倍预算最后产出的质量还下降了——因为新规划虽然拆得更细但把原来一条关键证据的获取路径给丢了。修正方案有两条。一是重规划不清空results和evidence只替换plan和cursor。这样新计划里的子目标如果能在已有results里找到对应产出执行节点可以跳过实际工作直接复用——实现上我加了个简单的匹配新子目标的goal与已有产出的原始goal相似度超过阈值用最朴素的字符重合率算就行不用上向量直接复用。二是失败原因要带进规划 prompt 但只带最近 5 条。带全部会撑爆上下文而且早期失败原因往往已经被后续调整解决了带进去反而误导。dead_ends的使用再强调一下。它的作用是让规划器别走回头路但它有个副作用如果设置得太激进每次失败都追加一个指纹规划器会在几次重规划后被自己的历史锁死无路可走。我的处理是只在scope global的失败时才追加指纹局部的格式错误、引用错误不追加——那些是执行问题不是路线问题。这个区分让重规划的成功率提升明显。5. 常见问题速查与排查手法这一章是我自己的排查表出问题先对着表看能省掉大量瞎猜的时间。5.1 症状、病因、处置速查表症状最可能的原因处置手法卡在同一轮反复重试rounds一直涨但score不变验证标准与执行能力不匹配或失败原因没传进执行 prompt打印fail_reasons确认有传递检查accept是否要求过细replans很快打满规划每次都很像计划指纹失效子目标文本改写绕过哈希改用子目标关键词集合做指纹别用全文哈希验证全过但最终产出明显不对目标校验只看单个子目标没做全局终检finalize前加一次整体一致性检查比对原任务跑到一半抛递归上限异常recursion_limit小于等于max_rounds调到max_rounds 5以上或降低max_rounds证据引用校验老是拦截执行节点被要求引用但没有可用证据检查工具是否真的返回了内容空结果也产生一条证据占位产出风格飘忽时而详细时而极简accept描述太模糊模型自由发挥在accept里写清数量、格式、必含要素重规划后质量反而下降重规划清空了中间结果或被dead_ends锁死保留results只在scopeglobal时追加指纹表里第一行是我遇到最多的。表现是轨迹里rounds一路涨每次验证都返回同一个理由比如缺少时间戳对比但执行节点下一次产出还是没加时间戳对比。原因通常是fail_reasons只传了最近一条而最近一条恰好是别的问题或者执行 prompt 里失败原因的位置太靠后被长上下文淹没了。我的修正办法是把失败原因放在 prompt 的最前面紧跟在子目标之后并且加一句本次产出的第一件事就是修正这个问题。位置和措辞都影响很大别小看。5.2 三个真实调试片段片段一验证器永远通过。有个批次跑下来通过率 100%我当时还高兴了一下抽查三条产出发现两条明显不对。查下来是check_goal的 prompt 里我写了请判断产出是否满足验收标准满足则通过模型对满足的理解非常宽松只要沾边就给通过。改成给评分区间后通过率降到 62%这才是正常水平。这件事让我确立了原则验证 prompt 里绝不用二值判断词一律用带区间的评分二值判断是模型最容易敷衍的地方。片段二死循环。一个任务跑了十几轮每次验证都是 score 0.55scope判局部于是不停重试。查下来是这个子目标本身就不可能完成——它需要一份任务里根本没提供的数据。执行节点每次都很诚实地写未找到该数据验证器也很诚实地判不达标。双方都没错但系统就这么僵住了。修正方案是给执行节点加一个出口如果连续两次判定所需信息在任务中不存在产出里必须写INSUFFICIENT: 说明验证器看到这个标记就把它转成scopeglobal让规划层去处理。这个设计后来救了我好几次。片段三指纹误伤。有个任务首轮规划就被判计划撞车重试一次还是撞车。查了半天发现是我早期版本里dead_ends的更新逻辑有 bug——replans 0判断写错了位置首轮就把自己的指纹记进去了。这种 bug 特别隐蔽因为系统表现是能跑但总在重规划看起来像模型能力问题。教训是计数器和去重逻辑必须写单测尤其是首轮、重规划后第一轮、达到上限这三个边界。提示排查任何 Agent 问题第一步永远是打印完整状态快照而不是读日志猜。状态里rounds、cursor、score、scope四个值一摆出来八成的问题当场就能定位。6. 把这套模式复用到别处的几个思路写完这套之后我拿它改过几个不同的场景发现真正可复用的是那个规划—执行—验证的三段式骨架和状态约定具体的 prompt 和工具反而是最不值钱的部分。这里说两个延展方向都是我实际用过觉得值得做的。6.1 人工介入点怎么放才不别扭interrupt_before能卡在任意节点前但卡的位置很有讲究。我试过三种放法结论是卡在finalize之前最实用卡在executor之前最鸡肋卡在planner之后是折中方案。卡在finalize前的好处是信息量最大——用户看到的是完整的子目标清单和每步产出做的是放行还是打回这一个决定认知负担低。卡在executor前的话用户要在没有任何产出的情况下判断这个子目标定得对不对大部分人给不出有效意见只会点继续。卡在planner之后是个不错的折中特别适合任务描述本身模糊的场景先让规划器拆一版人工扫一眼子目标清单不对就改任务描述重跑成本很低。我现在默认配的是planner之后和finalize之前各卡一次中间过程全自动跑起来节奏比较舒服。6.2 把验证器抽成独立服务同一个验证逻辑如果多个 Agent 都要用抽成独立服务是最划算的。我抽过一次接口就三个字段任务描述、待验证产出、验收标准返回评分和理由。抽出来之后有三个明显好处一是验证逻辑改一次全部生效不用在五个 Agent 里同步改 prompt二是可以单独给验证器做样本校准和版本管理出了问题能回滚三是方便做双验证器交叉——两个不同 prompt 的验证器同时跑结论不一致时人工介入这招在质量要求高的场景里特别有用。抽服务的时候有个坑要避开不要把执行过程的完整上下文传过去。传任务、产出、验收标准这三样就够了。原因第 1.1 节说过——验证器看到的上下文越多越容易变成自我一致性检查。我第一次抽的时候把整个状态都传过去了验证通过率立刻虚高后来砍到三个字段才恢复正常。最后分享个小技巧收尾。这套东西调稳之后我把每次运行的verdict序列存下来做成一个失败分布统计多少比例卡在结构层、多少卡在依据层、多少卡在目标层。跑了两周我发现结构层占比一直有 15% 左右怎么调 prompt 都降不下去后来才意识到那是工具返回空结果导致的连锁反应——工具没拿到东西执行节点就没法挂引用结构层就拦下来了。改成工具返回空也必须生成一条占位证据之后这个比例掉到 3%。这类分布统计比单条轨迹有用得多它能告诉你系统的瓶颈到底在哪个环节而不是让你在几十条日志里找一个偶发问题。