接上一篇。第二个项目(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 条任务偏简单(都是单跳问答),是为量格式失败率设计的,不是为量答案质量。这个边界到第五节会变得很重要。
三、数据:两轮 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 写法,大概是很多人都会写的:
python
m = re.search(r"\{.*?\}", text) # 非贪婪,取第一个 {...}
非贪婪匹配会在第一个 } 就收尾。G3 的参数长这样:
json
{"skills": [{"skill": "Python", "weight": 3.0, "hit": true}, ...], "has_relevant_project": true}
第一个 } 属于内层对象 ,截出来是半截 JSON,json.loads 直接失败。还有一条更隐蔽的同类问题:{"query": "括号 } 出现在字符串里"}------字符串字面量里的 } 也会让正则提前收尾。
关键是:这两类情况里模型的输出完全合规。而截错之后报出来的错是"JSON 不合法"------看起来像模型的锅。
这是我真实实验里 G3 第 1 条的原始输出(两轮逐字相同):
vbnet
[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------非贪婪正则解析不了它。
修法不稀奇,分账才值钱
正确的取法是扫一遍、维护深度、跳过字符串字面量:
python
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 处理
这段代码本身没有任何稀奇之处。我认为值钱的是让它和那个会错的正则同时跑、分开计数:
python
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 里能看到完整过程,三步走的全是"合理"的路:
- 第 1 步用
数据库检索简历 → 召回的 top-3 是「教育背景」「项目经历」,「专业技能」那块压根没进来(embedding 召回的局限,不是模型的错); - 第 2 步模型把上一步观察里出现的词拿来当新 query(
数据库原理)→ 越查越窄,还是没捞到「专业技能」那块; - 第 3 步直接
Final Answer收工。
每一步单看都没毛病,合起来是一个漏答案。手写 ReAct 的短板在这里暴露得很准:循环里没有任何机制判断"检索到的东西够不够回答这个问题"。模型拿到什么就答什么,答完就收工。
这恰好是"要不要上框架"最实在的论据。框架的价值不在于省掉那个 while 循环------循环谁都会写------而在于能让"证据够不够"变成一个可插拔的校验节点,在收工之前拦一道。手写版里这个判断散落在模型的自觉里,而模型的自觉靠不住。
必须划清的边界 :30 条里只有这 1 条暴露问题,比例太小,不能写成"答案错误率 3.3%"------这批任务本来就是为量格式失败率设计的单跳问答,对答案质量没有区分度。这条只能算定性发现,答案质量要等专门的评测集(多跳、含负样本)来量。
六、顺带踩的坑:Observation 是上下文杀手
还有一个不跑批量实验根本发现不了的坑:观察值原样塞回上下文,循环活不过第 3 步。
一次 web_search 返回 5 条、每条正文约 690 字,一条观察就是 3500 字------比问题本身大两个数量级。第 3 步上下文就顶满了,后面全是在稀释。
解法是按工具裁剪 观察:web_search 只留标题、来源和正文前 160 字;calc_match_score 只留分数和算式。这不是优化,是能不能跑完的前提------裁剪规则的原则是"留下模型判断下一步所需的最小信息"。
七、这个实验没回答的问题
按惯例,最后是诚实清单:
- 答案质量没有量化。第五节只有 1 条定性发现,要等 30 条评测集(多跳 + 负样本)才有分母。
- prompt 是刻意朴素的:只讲格式、不给 few-shot 示例。所以这里的 0% 格式失败反映的是机制本身,不是 prompt 工程的上限------堆示例当然可以更低,但那测的就是调参水平了。
- 两轮 × 30 条,样本仍然小。逐任务一致是很强的信号,但"稳定"要更大样本才能钉死。
- 没测多轮工具链。任务最多用到 3 步,"第 1 步的结果影响第 4 步的工具选择"这类场景没覆盖。
八、下一步
- 30 条评测集(多跳问答 + 负样本),把答案质量从定性变定量------这也是整个项目可复现性补齐的最后一块。
- function calling 版对照组:同一批工具函数,换原生 function calling 接入,和手写 ReAct、LangGraph 三方对比。解析器丢失率这一项在 function calling 下理论上不存在,正好用来验证"这 15.9% 是不是格式化接入的固有成本"。
- 回主线,写完写作 Agent,把四节点链路收口。
项目仓库 :gitee.com/epitome-of-the-sky/jobfit-agent(手写 ReAct 在 app/react_manual.py,批量实验在 eval/,离线单测 python -m eval.test_parse_step 不联网可跑)
系列文章:
- 第 1 篇:毕设复盘 ------ RAG 检索链路消融实验
- 第 2 篇:手写 ReAct 30 任务实测(本篇)