在智能体任务中,报出“JSON 不合法”并不意味着模型一定生成了错误格式。为了确认 ReAct 循环究竟在哪一环失效,我用同一组工具函数手写编排逻辑,并对任务进行重复测试,分别记录模型输出、解析结果和工具返回。结果显示,真正需要修复的不是提示词或模型,而是无法处理嵌套结构的解析代码。
接上一篇。第二个项目(LangGraph 多智能体岗位匹配)做到一半,我干了件框架时代的"逆行"事:不用
create_react_agent,不用任何框架,手写了一遍 ReAct 循环,然后在 30 个任务上跑了批量实验,同一输入连跑两轮。跑之前我预判了三个坑:模型忘了输出 Observation、JSON 参数缺引号、选了不存在的工具。这三个一个都没出现——63 步里 glm-4-flash 一次都没违反格式,33 次工具调用全部成功。
真正"丢"掉的 10 步,全是我自己写的那个 JSON 解析器干的。而且如果不做对照计数,这口锅会被顺理成章地扣在模型头上——结论就完全反了。这篇写实验怎么设计、数据说了什么,以及我为什么庆幸自己把"谁失败"这件事拆开来记了。
先说清楚:手写不是为了省依赖,是当对照组。
LangGraph 这类框架把循环封起来了,也把"出错时到底哪一环坏了"一起封起来了。结果不对的时候,你面对的是三种长得一模一样的现场:
在框架里这三种情况都表现为"跑完了,结果不对",然后你开始猜。手写一遍,每一步的原始输出、解析判定、工具返回都在 trace 里,才有可能把失败归因清楚。
但对照组有个前提:两边必须用同一批工具函数。如果手写版和框架版调的不是同一份 web_search,测出来的差异就分不清是编排层的还是工具实现的。所以我的 TOOLS 只是一张「名字 → 函数」的表,工具是普通 Python 函数,不是 langchain 的 Tool 对象——两条链路直接调同一批函数,差异才全部落在编排这一层。
另一个诚实的交代:上一篇结尾我预告的是"150 行拆开工具调用循环"。实际写完 383 行。多出来的不是循环本身——while steps < max_steps 那个循环只有零头——是失败分账的部分:解析失败按类型分开计数、观察按工具裁剪、每步原始输出进 trace。做完才发现,"让循环跑起来"和"让失败说得清楚"是两件工作量差一个量级的事。
任务集 3 组 × 10 条。**分组的目的只有一个:让失败可归因。**笼统的"失败率 15.9%"解释不了任何事,分组才能回答"失败集中在哪类任务上":
| 组 | 任务类型 | 用到的工具 | 参数形态 |
|---|---|---|---|
| G1 | 企业信息检索 | web_search | 单参数 |
| G2 | 简历证据检索 | search_resume | 单参数 |
| G3 | 算匹配分 | calc_match_score | 嵌套结构(skills 是对象数组) |
配置:glm-4-flash(temperature=0.0),max_steps=6,每组 10 条任务连跑两轮。
统计口径先钉死,这两个定义后面所有数字都建立在上面:
还有一个要提前交代的边界:这 30 条任务偏简单(都是单跳问答),是为量格式失败率设计的,不是为量答案质量。这个边界到第五节会变得很重要。
| 观测项 | 第 1 轮 | 第 2 轮 | 结论 |
|---|---|---|---|
| 收敛(拿到 Final Answer) | 30/30 | 30/30 | ✅ |
| 总步数 | 63 | 63 | ✅ 完全一致 |
| 平均步数 / 任务 | 2.10 | 2.10 | ✅ |
| 模型格式失败率 | 0/63 = 0.0% | 0/63 = 0.0% | ✅ |
| 解析器丢失率 | 10/63 = 15.9% | 10/63 = 15.9% | ✅ |
| 未调用任何工具就作答 | 0 条 | 0 条 | ✅ |
| 工具调用失败(选错 / 参数错 / 报错) | 0 | 0 | ✅ |
| 平均耗时 / 任务 | 11.0 s | 11.1 s | ❌ 波动 |
| prompt tokens | 54893 | 54900 | ❌ 微漂 |
"逐任务一致"比汇总一致更有说服力:两轮里每一条任务的步数、调用的工具、解析器丢失步数完全相同——3 条任务跑了 3 步,其余 27 条各 2 步;解析器丢失全部落在 G3 那 10 条上,每条恰好丢 1 步。漂的只有耗时、token 和答案文本。
这个模式和我在第一个项目里观察到的一样:结构化指标稳定,自由文本和耗时必漂。写博客能引用的数字,只有前一类。
按组拆开,事情就清楚了:
| 组 | 总步数 | 模型格式失败 | 解析器丢失 | 丢失率 |
|---|---|---|---|---|
| G1(单参数检索) | 21 | 0 | 0 | 0.0% |
| G2(单参数检索) | 22 | 0 | 0 | 0.0% |
| G3(嵌套参数) | 20 | 0 | 10 | 50.0% |
| 合计 | 63 | 0 | 10 | 15.9% |
**模型在 63 步里一次都没有违反格式。**丢掉的 10 步全部是解析器截错 JSON,而且全部集中在参数是嵌套结构的 G3——单参数组(G1/G2)是零。
我最初用的取 JSON 写法,大概是很多人都会写的:
m = re.search(r"{.*?}", text) # 非贪婪,取第一个 {...}
非贪婪匹配会在第一个 } 就收尾。G3 的参数长这样:
{"skills": [{"skill": "Python", "weight": 3.0, "hit": true}, ...], "has_relevant_project": true}
第一个 } 属于内层对象,截出来是半截 JSON,json.loads 直接失败。还有一条更隐蔽的同类问题:{"query": "括号 } 出现在字符串里"}——字符串字面量里的 } 也会让正则提前收尾。
关键是:这两类情况里模型的输出完全合规。而截错之后报出来的错是"JSON 不合法"——看起来像模型的锅。
这是我真实实验里 G3 第 1 条的原始输出(两轮逐字相同):
[step 1] 解析=action naive_ok=False
Action: calc_match_score
Action Input: {"skills": [{"skill": "Python", "weight": 3.0, "hit": true},
{"skill": "RAG", "weight": 3.0, "hit": true},
{"skill": "Docker", "weight": 1.0, "hit": false}],
"has_relevant_project": true}
一个多余字符都没有,naive_ok 却是 False——非贪婪正则解析不了它。
正确的取法是扫一遍、维护深度、跳过字符串字面量:
def _balanced_json(text: str) -> str | None:
start = text.find("{")
if start == -1:
return None
depth, in_str, escaped = 0, False, False
for i in range(start, len(text)):
ch = text[i]
if in_str:
if escaped:
escaped = False
elif ch == "\":
escaped = True
elif ch == '"':
in_str = False
elif ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None # 括号没配平,按 bad_json 处理
这段代码本身没有任何稀奇之处。我认为值钱的是让它和那个会错的正则同时跑、分开计数:
parsed = _balanced_json(raw) # 主路径:括号配平
naive = _NAIVE_JSON_RE.search(raw) # 对照:非贪婪正则(就是容易写错的那种)
# parsed 成功而 naive 失败 → 这一步记"解析器丢失",不记"模型格式失败"
parse_step 返回里带一个 naive_ok 字段,专门记"这一步如果交给非贪婪正则能不能过"。这样"模型的锅"和"解析器的锅"是两本账。配套还写了 12 条离线单测——不联网、不调模型,嵌套截断和字符串截断各占几条,跑一下就复现。
如果没做这个分账,我的实验结论会写成:"glm-4-flash 格式失败率 15.9%"。方向完全相反——不是模型不行,是我预写的解析器不行。归因错了,后面的动作就全错:你会去改 prompt、换模型、堆 few-shot,而真正该改的是十几行解析代码。
上面所有数字都在说"这套循环跑得很稳"。但 30 条收敛、0 格式失败,不代表答案都对。逐条读答案之后,发现 1 条答漏了:
| 任务 | 问题 | 模型答案 | 实际情况 |
|---|---|---|---|
| G2-02 | 我的简历里提到过哪些数据库? | "提到了数据库原理这门课程" | 简历「专业技能」块里还写着"了解向量数据库(Chroma)",漏了 |
trace 里能看到完整过程,三步走的全是"合理"的路:
数据库 检索简历 → 召回的 top-3 是「教育背景」「项目经历」,「专业技能」那块压根没进来(embedding 召回的局限,不是模型的错);数据库原理)→ 越查越窄,还是没捞到「专业技能」那块;Final Answer 收工。每一步单看都没毛病,合起来是一个漏答案。手写 ReAct 的短板在这里暴露得很准:循环里没有任何机制判断"检索到的东西够不够回答这个问题"。模型拿到什么就答什么,答完就收工。
这恰好是"要不要上框架"最实在的论据。框架的价值不在于省掉那个 while 循环——循环谁都会写——而在于能让"证据够不够"变成一个可插拔的校验节点,在收工之前拦一道。手写版里这个判断散落在模型的自觉里,而模型的自觉靠不住。
必须划清的边界:30 条里只有这 1 条暴露问题,比例太小,不能写成"答案错误率 3.3%"——这批任务本来就是为量格式失败率设计的单跳问答,对答案质量没有区分度。这条只能算定性发现,答案质量要等专门的评测集(多跳、含负样本)来量。
还有一个不跑批量实验根本发现不了的坑:观察值原样塞回上下文,循环活不过第 3 步。
一次 web_search 返回 5 条、每条正文约 690 字,一条观察就是 3500 字——比问题本身大两个数量级。第 3 步上下文就顶满了,后面全是在稀释。
解法是按工具裁剪观察:web_search 只留标题、来源和正文前 160 字;calc_match_score 只留分数和算式。这不是优化,是能不能跑完的前提——裁剪规则的原则是"留下模型判断下一步所需的最小信息"。
按惯例,最后是诚实清单:
项目仓库:gitee.com/epitome-of-the-sky/jobfit-agent(手写 ReAct 在 app/react_manual.py,批量实验在 eval/,离线单测 python -m eval.test_parse_step 不联网可跑)
系列文章: