手写 ReAct 跑了 30 个任务:模型 0 格式失败,丢的 10 步全是我写的解析器

接上一篇。第二个项目(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. 第 1 步用 数据库 检索简历 → 召回的 top-3 是「教育背景」「项目经历」,「专业技能」那块压根没进来(embedding 召回的局限,不是模型的错);
  2. 第 2 步模型把上一步观察里出现的词拿来当新 query(数据库原理)→ 越查越窄,还是没捞到「专业技能」那块;
  3. 第 3 步直接 Final Answer 收工。

每一步单看都没毛病,合起来是一个漏答案。手写 ReAct 的短板在这里暴露得很准:循环里没有任何机制判断"检索到的东西够不够回答这个问题"。模型拿到什么就答什么,答完就收工。

这恰好是"要不要上框架"最实在的论据。框架的价值不在于省掉那个 while 循环------循环谁都会写------而在于能让"证据够不够"变成一个可插拔的校验节点,在收工之前拦一道。手写版里这个判断散落在模型的自觉里,而模型的自觉靠不住。

必须划清的边界 :30 条里只有这 1 条暴露问题,比例太小,不能写成"答案错误率 3.3%"------这批任务本来就是为量格式失败率设计的单跳问答,对答案质量没有区分度。这条只能算定性发现,答案质量要等专门的评测集(多跳、含负样本)来量。

六、顺带踩的坑:Observation 是上下文杀手

还有一个不跑批量实验根本发现不了的坑:观察值原样塞回上下文,循环活不过第 3 步。

一次 web_search 返回 5 条、每条正文约 690 字,一条观察就是 3500 字------比问题本身大两个数量级。第 3 步上下文就顶满了,后面全是在稀释。

解法是按工具裁剪 观察:web_search 只留标题、来源和正文前 160 字;calc_match_score 只留分数和算式。这不是优化,是能不能跑完的前提------裁剪规则的原则是"留下模型判断下一步所需的最小信息"。

七、这个实验没回答的问题

按惯例,最后是诚实清单:

  1. 答案质量没有量化。第五节只有 1 条定性发现,要等 30 条评测集(多跳 + 负样本)才有分母。
  2. prompt 是刻意朴素的:只讲格式、不给 few-shot 示例。所以这里的 0% 格式失败反映的是机制本身,不是 prompt 工程的上限------堆示例当然可以更低,但那测的就是调参水平了。
  3. 两轮 × 30 条,样本仍然小。逐任务一致是很强的信号,但"稳定"要更大样本才能钉死。
  4. 没测多轮工具链。任务最多用到 3 步,"第 1 步的结果影响第 4 步的工具选择"这类场景没覆盖。

八、下一步

  1. 30 条评测集(多跳问答 + 负样本),把答案质量从定性变定量------这也是整个项目可复现性补齐的最后一块。
  2. function calling 版对照组:同一批工具函数,换原生 function calling 接入,和手写 ReAct、LangGraph 三方对比。解析器丢失率这一项在 function calling 下理论上不存在,正好用来验证"这 15.9% 是不是格式化接入的固有成本"。
  3. 回主线,写完写作 Agent,把四节点链路收口。

项目仓库gitee.com/epitome-of-the-sky/jobfit-agent(手写 ReAct 在 app/react_manual.py,批量实验在 eval/,离线单测 python -m eval.test_parse_step 不联网可跑)

系列文章

相关推荐
桃西西呀1 小时前
文件监控 Agent 为什么总在关键时刻掉链子
人工智能·llm·agent
夫子3961 小时前
【第三部分:第一个 Agent 应用】13. 为 Agent 增加记忆能力:不是记住一切,而是在需要时想起正确的信息
llm·agent
PPPCODE1 小时前
从零手写一个MCP Agent服务:stdio与SSE两种连接模式的踩坑实录
llm·agent·mcp
多云行者2 小时前
什么是LLM Gateway?定义、技术栈与落地方式详解
网关·llm·gateway·api·传统
星野云联AIoT技术洞察2 小时前
物联网平台源码交付、私有化部署和 SaaS 怎么选:先算清责任与退出成本
llm·私有化部署·saas·dify·物联网平台·iot平台·设备管理平台
prog_61033 小时前
【笔记】用agent手搓agent(一)
人工智能·llm·大语言模型·agent
闲研随记3 小时前
RL算法学习:ArgMaxRL
算法·llm·强化学习·rl
stereohomology4 小时前
一个可能有用的经验:api key在Workbuddy或Trae IDE上使用
人工智能·llm·薅羊毛
米小虾15 小时前
LLM评测的失效与依赖感知聚合
人工智能·llm