news 2026/11/4 8:14:54

基于LangGraph的自我路径规划与结果验证Agent实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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当前处理到第几个子目标每完成一个 +1
resultsdict已完成的子目标 ID 到产出的映射只增不减
evidencelist证据池,带 ID 与摘要累加,需瘦身
fail_reasonslist失败记录,供重试与重规划参考累加,需去重
dead_endsset已判定走不通的计划指纹累加,用于拒绝重复规划
roundsint总循环次数单调递增
replansint重规划次数单调递增
verdictdict最近一次验证结论每次验证覆盖

关键约定有两条。一是只增字段用于回滚:results和evidence只增不减,重规划时不清空。原因很实在——重规划最怕把前面已经做对的部分推翻重来,白白烧钱。二是覆盖字段用于信号:verdict每次验证整体替换,它表达的是"此刻的判断",不是历史。这两类字段混用是 bug 高发区,我在早期版本里就是因为verdict用追加语义,导致路由函数读到了上一轮的旧结论,出现了"验证说不通过但路由走了通过分支"这种鬼故事。

2.2 节点职责边界

职责边界不清是第二个大坑。我最初的executor里顺手加了"如果发现计划有问题就自己调整一下"的逻辑,结果planner和executor互相打脸,轨迹乱成一团。后来的原则是:每个节点只做一件事,只允许通过状态字段影响别的节点,不许直接改别人的成果。

planner的输入是task+fail_reasons+dead_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 会不会跑飞。我的路由逻辑按优先级从高到低排:

  1. rounds >= max_rounds→ 强制走finalize(带降级标记,明确告诉用户这是未完全验证的结果)。
  2. replans >= max_replans且验证未通过 → 走finalize,同样带降级标记。
  3. verdict.passed == True且cursor >= len(plan) - 1→ 全部完成,走finalize。
  4. verdict.passed == True且还有剩余子目标 →cursor += 1,回executor处理下一个。
  5. verdict.passed == False且scope == "local"→ 回executor重试当前子目标。
  6. verdict.passed == False且scope == "global"→ 回planner重规划。
  7. 兜底: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( task=state["task"], dead_ends="\n".join(state["dead_ends"]) or "无", fail_reasons="\n".join(state["fail_reasons"][-5:]) or "无", ) out = llm.json(prompt, schema=PLAN_SCHEMA) steps = out["steps"] fp = fingerprint(steps) if fp in state["dead_ends"]: # 计划撞车,强制追加约束再试一次 out = llm.json(prompt + f"\n\n你刚给出的方案已被判定走不通(指纹 {fp}),必须换思路。", schema=PLAN_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( goal=step["goal"], accept=step["accept"], tools=list(TOOLS.keys()), reason=reason) + transcript) call = parse_tool_call(out) if not call: break result = TOOLS[call["name"]](**call["args"]) eid = f"E{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( goal=step["goal"], accept=step["accept"], tools=list(TOOLS.keys()), reason=reason) + 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 = {f"E{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, schema=VERDICT_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(checkpointer=MemorySaver(), 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模式把每个节点的输入输出摘要打出来,格式大概是round=3 | node=verifier | score=0.62 | layer=goal | scope=local | 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,模型引用哪一个都算"存在",幻觉就漏过去了。我现在的做法是用f"E{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;只在scope=global时追加指纹

表里第一行是我遇到最多的。表现是轨迹里rounds一路涨,每次验证都返回同一个理由,比如"缺少时间戳对比",但执行节点下一次产出还是没加时间戳对比。原因通常是fail_reasons只传了最近一条,而最近一条恰好是别的问题;或者执行 prompt 里失败原因的位置太靠后,被长上下文淹没了。我的修正办法是把失败原因放在 prompt 的最前面,紧跟在子目标之后,并且加一句"本次产出的第一件事就是修正这个问题"。位置和措辞都影响很大,别小看。

5.2 三个真实调试片段

片段一:验证器"永远通过"。有个批次跑下来通过率 100%,我当时还高兴了一下,抽查三条产出发现两条明显不对。查下来是check_goal的 prompt 里我写了"请判断产出是否满足验收标准,满足则通过",模型对"满足"的理解非常宽松,只要沾边就给通过。改成给评分区间后通过率降到 62%,这才是正常水平。这件事让我确立了原则:验证 prompt 里绝不用二值判断词,一律用带区间的评分,二值判断是模型最容易敷衍的地方。

片段二:死循环。一个任务跑了十几轮,每次验证都是 score 0.55,scope判局部,于是不停重试。查下来是这个子目标本身就不可能完成——它需要一份任务里根本没提供的数据。执行节点每次都很诚实地写"未找到该数据",验证器也很诚实地判"不达标"。双方都没错,但系统就这么僵住了。修正方案是给执行节点加一个出口:如果连续两次判定"所需信息在任务中不存在",产出里必须写INSUFFICIENT: 说明,验证器看到这个标记就把它转成scope=global,让规划层去处理。这个设计后来救了我好几次。

片段三:指纹误伤。有个任务首轮规划就被判"计划撞车",重试一次还是撞车。查了半天发现是我早期版本里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%。这类分布统计比单条轨迹有用得多,它能告诉你系统的瓶颈到底在哪个环节,而不是让你在几十条日志里找一个偶发问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 8:13:47

Python装库避坑指南:OpenCV、PyTorch、transformers环境配置全攻略

做Python开发这几年&#xff0c;我最大的感触是&#xff1a;写代码的时间经常没有装库的时间多。尤其是刚接触 OpenCV、PyTorch、transformers 这批库的朋友&#xff0c;几乎每天都要跟“ModuleNotFoundError”做斗争。今天想把这些年帮人“载入”各种Python库的经验梳理一遍&a…

作者头像 李华
网站建设 2026/11/4 8:12:47

Hermes Agent:安全检测器的法医级可观测性探针

1. 项目概述&#xff1a;这不是一个“调用API”的简单活儿&#xff0c;而是一次对检测逻辑的深度解剖“利用hermes agent 详细分析hunter检测器”——这个标题乍看像一句技术指令&#xff0c;但真正做过安全检测系统逆向或规则验证的人一眼就明白&#xff1a;它背后藏着三层硬需…

作者头像 李华
网站建设 2026/11/4 8:11:08

数字电源方案:从模拟环路到数字闭环的完整设计路径

简介&#xff1a;面向电源设计工程师、系统架构师及电子相关专业学习者&#xff0c;这份文档围绕数字电源控制器与芯片应用展开&#xff0c;重点解决从模拟电源向数字控制过渡时的概念理解与选型设计问题。内容从数字电源的基本特征与定义切入&#xff0c;梳理其灵活性、供电性…

作者头像 李华
网站建设 2026/11/4 8:11:01

LoRa技术在文物保护环境监测中的核心优势与应用

1. 项目背景与行业痛点 文物保护领域长期面临环境监测的严峻挑战。传统监测手段在古建筑、石窟寺、地下墓葬等特殊场景中往往束手无策——有线部署会破坏文物本体&#xff0c;常规无线方案又难以穿透厚重的墙体结构。某省级博物馆的监测数据显示&#xff0c;其地下文物库房的温…

作者头像 李华
网站建设 2026/11/4 8:10:17

命令行工具创作诗歌:技术人的浪漫实践

1. 当命令行遇上十四行诗&#xff1a;技术人的浪漫主义实践在终端窗口里写诗&#xff1f;这听起来像是文科生和工程师的一次跨界约会。作为一名在Linux系统里摸爬滚打十年的老运维&#xff0c;我从未想过有一天会把grep和押韵词典放在同一个脚本里。但正是这种看似荒谬的组合&a…

作者头像 李华
网站建设 2026/11/4 8:09:08

广告营销工程:数据驱动的精准投放与效果优化

1. 广告营销工程概述广告营销工程是信息科学与工程学在商业领域的重要应用分支&#xff0c;它通过系统化的方法论和技术手段&#xff0c;将广告活动从传统的经验驱动转变为数据驱动的科学决策过程。我在数字营销领域工作多年&#xff0c;见证了从粗放式投放到精准化运营的转变过…

作者头像 李华