Day 09 / 共 60 天 · 阶段2 Agent 深入
parser.py:把 LLM 的一段文字翻译成"动作"或"答案"
Day 08 的循环里有一句 process_llm_response,它把 LLM 吐出的自由文本翻译成结构化的 AgentAction(要调工具)或 AgentFinish(给答案)。这套翻译逻辑就在 agents/parser.py,一共不到 180 行,却是文本 ReAct 模式的"命门"——解析对了循环才转得动,解析错了框架靠它生成"纠错提示"让模型重来。今天逐行读它。
📍 你在 60 天里的位置(阶段2 Agent 深入 · 共 6 天)
阶段1 入门 D01-06→
D07 全字段→
D08 执行循环→
D09 输出解析→
D10 单步执行→
D11 工具缓存→
D12 LiteAgent→
阶段3 Task
💡 先用一个类比兜住今天
解析器就像一个翻译兼质检员。LLM 说的是一段"人话"(自由文本),可循环需要的是"机器能执行的指令"。翻译员读这段人话,判断它到底在说三件事之一:① "我想好了,答案是……"(Final Answer → AgentFinish);② "帮我调用某工具,参数是……"(Action/Action Input → AgentAction);③ 说了一堆但既没答案也没明确要调工具(格式不合规 → 退回去让它重说)。翻译员只认这三种情况,把混乱的自然语言收敛成三条确定的路。
L01
痛点:LLM 吐的是文字,循环要的是结构
🤔 痛点Day 08 的循环里有个关键分叉:
if isinstance(formatted_answer, AgentAction) 就执行工具,否则当最终答案退出。可 LLM 返回的是一大段纯文本——里面可能夹着 Thought:、Action:、Final Answer:,还可能有 markdown 代码块、破损的 JSON、多余的星号。循环怎么知道"这段话到底是要调工具还是给答案"?工具名和参数又怎么从文字里精确抠出来?这就是解析器要解决的问题。💡 一句话本质
parse(text) 是一个纯函数:吃一段字符串,吐一个 AgentAction 或 AgentFinish;要是文本格式不合规范,就抛 OutputParserError 并附上"你该怎么改"的提示。它把"不可控的自然语言"收敛成"可控的三种结果",是文本 ReAct 能跑起来的前提。文件开头就点明了它的职责(parser.py:1):
# parser.py:1(模块 docstring)
"""Agent output parsing module for ReAct-style LLM responses.
This module provides parsing functionality for agent outputs that follow
the ReAct (Reasoning and Acting) format, converting them into structured
AgentAction or AgentFinish objects."""
大白话你可以把
parser.py 想成一台"文本分拣机":进料口是 LLM 的原始输出,出料口有三个筐——"要调工具"筐、"给答案"筐、"格式不对退回"筐。今天就看它靠什么规则把每段文本丢进对的筐。L02
两个数据类 + 一个异常:解析的"出料筐"
解析结果只有两种类型,都是简单的 @dataclass(parser.py:25):
# parser.py:25
@dataclass
class AgentAction: # "我要调工具"
thought: str # 调工具前的思考
tool: str # 工具名
tool_input: str # 工具参数(字符串/JSON)
text: str # 原始完整文本
result: str | None = None # 工具执行后回填的结果
@dataclass
class AgentFinish: # "我给出最终答案"
thought: str
output: str | BaseModel # 最终答案(可以是纯文本或结构化对象)
text: str
# parser.py:45
class OutputParserError(Exception):
def __init__(self, error: str) -> None:
self.error = error # ★错误消息即"给模型的纠错提示"
super().__init__(error)
AgentAction.tool / tool_input工具名 + 参数。循环拿到它就去执行对应工具。result 初始 None,工具跑完后回填(Day 08 见过)。AgentFinish.output最终答案。类型是 str | BaseModel——既能是纯文本,也能是结构化 Pydantic 对象(阶段3 讲结构化输出时用)。两个类都有 text都保留一份原始完整文本。为什么?因为要把它 append 回消息历史(Day 08 的 _append_message(formatted_answer.text)),让下一圈 LLM 看到自己上一步说了啥。OutputParserError.error★关键:这个异常携带的 error 字符串,就是后面要"喂回给模型"的纠错提示。异常不只是报错,还是"教模型改正"的载体。图注:一进三出。前两个是正常返回值,第三个是异常——但它带着"怎么改"的信息,供循环自愈。
L03
parse:一段文本走哪条路
核心函数 parse(parser.py:62)的判断顺序很讲究:
# parser.py:62
def parse(text: str) -> AgentAction | AgentFinish:
thought = _extract_thought(text)
includes_answer = FINAL_ANSWER_ACTION in text # 文本里有 "Final Answer:" 吗
action_match = ACTION_INPUT_REGEX.search(text) # 匹配 Action + Action Input
if includes_answer: # ① 有最终答案 → 优先当 Finish
final_answer = text.split(FINAL_ANSWER_ACTION)[-1].strip()
if final_answer.endswith("```"): # 收尾多余的代码块围栏
count = final_answer.count("```")
if count % 2 != 0:
final_answer = final_answer[:-3].rstrip()
return AgentFinish(thought=thought, output=final_answer, text=text)
if action_match: # ② 有 Action → 当 AgentAction
action = action_match.group(1)
clean_action = _clean_action(action)
action_input = action_match.group(2).strip()
tool_input = action_input.strip(" ").strip('"')
safe_tool_input = _safe_repair_json(tool_input) # 修破损 JSON(L06)
return AgentAction(thought=thought, tool=clean_action,
tool_input=safe_tool_input, text=text)
if not ACTION_REGEX.search(text): # ③ 连 Action 关键字都没有 → 报错并提示
raise OutputParserError(f"{MISSING_ACTION_AFTER_THOUGHT_ERROR_MESSAGE}\n...")
if not ACTION_INPUT_ONLY_REGEX.search(text): # ④ 有 Action 但没 Action Input → 报错
raise OutputParserError(MISSING_ACTION_INPUT_AFTER_ACTION_ERROR_MESSAGE)
raise OutputParserError(f"{_I18N.slice('format_without_tools')}")
① includes_answer 优先★先看有没有 Final Answer:。有就直接当"答案"返回,连 Action 都不再看。这个"答案优先"的顺序是有意的(见本讲取舍)。去尾部 ```模型爱把答案包在 markdown 代码块里。如果结尾的 ``` 数量是奇数(没配对),说明是多余的围栏,剥掉——细节但实用。② action_match用正则一次抠出 group(1)=工具名、group(2)=参数。清洗后组装成 AgentAction。③④ 分级报错没答案也没 Action → 三种精确的报错:连 Action 都没写、写了 Action 没写 Action Input、格式整体不对。每种错都对应一句具体提示,而不是笼统"格式错误"。💡 设计取舍①:为什么 Final Answer 优先于 Action 检测?
模型有时会又想调工具又急着给答案,一段话里同时出现
Action: 和 Final Answer:。这时该听哪个?源码选择先认 Final Answer——一旦文本里有"最终答案",就认为模型已经想收尾了,直接结束。为什么不反过来(先认 Action)?因为如果先执行工具,模型明明已经给了答案,你还多调一次工具、多花一次钱、还可能把已成型的答案搅乱。"倾向于结束"比"倾向于继续"更省、更安全。这也和 constants.py 里那条 FINAL_ANSWER_AND_PARSABLE_ACTION_ERROR_MESSAGE("不能同时给 Action 和 Final Answer")呼应——框架明确不鼓励这种模糊输出。L04
正则常量:文本格式的"识别规则"
parse 用到的关键字和正则都集中在 constants.py(agents/constants.py:7):
# agents/constants.py:7
FINAL_ANSWER_ACTION = "Final Answer:"
# :18 匹配 "Action: <工具名> Action Input: <参数>",group(1)=工具名 group(2)=参数
ACTION_INPUT_REGEX = re.compile(
r"Action\s*\d*\s*:\s*(.*?)\s*Action\s*\d*\s*Input\s*\d*\s*:\s*(.*)", re.DOTALL)
# :21 只匹配有没有 "Action:"(判断是否漏写 Action)
ACTION_REGEX = re.compile(r"Action\s*\d*\s*:\s*(.*?)", re.DOTALL)
# :24 只匹配有没有 "Action Input:"
ACTION_INPUT_ONLY_REGEX = re.compile(r"\s*Action\s*\d*\s*Input\s*\d*\s*:\s*(.*)", re.DOTALL)
\s* 到处都是允许关键字周围有任意空白。模型排版不稳定(有的加空格、有的换行),用 \s* 宽容吸收,避免因为一个空格解析失败。\d* 允许编号匹配 Action 1:、Action2 Input: 这种带数字的变体。模型有时会自己加序号,正则提前兼容。(.*?) 非贪婪 vs (.*) 贪婪工具名用非贪婪 .*?(尽量短,抠到第一个 Action Input 就停);参数用贪婪 .*(尽量长,把后面所有内容当参数)。一短一长,正好切开工具名和参数。re.DOTALL让 . 也能匹配换行符——因为参数(尤其 JSON)经常跨多行。大白话:为什么不直接用
text.find("Action:")?
因为模型的输出格式五花八门:Action:、Action :、Action 1:、大小写、多空格……硬用字符串查找会漏掉一堆变体。正则把这些"合理的变体"一网打尽,用宽容的模式去适应"不那么听话的模型"。这就是解析器健壮性的第一道功夫。L05
抠出思考 + 清洗工具名
两个小助手函数,专治"文字里的杂质"。先看 _extract_thought(parser.py:131):
# parser.py:131
def _extract_thought(text: str) -> str:
thought_index = text.find("\nAction")
if thought_index == -1:
thought_index = text.find("\nFinal Answer")
if thought_index == -1:
return ""
thought = text[:thought_index].strip()
return thought.replace("```", "").strip()
# parser.py:149
def _clean_action(text: str) -> str:
return text.strip().strip("*").strip() # 去空白、去 markdown 星号、再去空白
找 \nAction 或 \nFinal Answer"思考"就是这两个关键字之前的所有文字。找到第一个出现的位置,截取它前面的部分。都找不到 → 返回空串边界处理:如果既没 Action 也没 Final Answer(纯闲聊),思考就当空。不崩。replace("```", "")把思考里混进来的 markdown 代码围栏去掉,保持 thought 干净。_clean_action 的 strip("*")模型爱把工具名写成 **search**(markdown 加粗)。这里剥掉星号,还原成干净的 search,否则工具名对不上、找不到工具。💡 为什么要这么多"清洗"?因为 LLM 是被训练来取悦人类阅读的——它天然爱加 markdown、加序号、加围栏,让输出"好看"。可这些"装饰"对机器解析是噪声。解析器的一大半代码其实都在把"给人看的排版"还原成"给机器用的裸值"。理解这一点,你就懂了为什么 Agent 框架里到处是 strip/replace/正则——它们是"人机格式鸿沟"的填缝剂。
L06
修破损 JSON:让"差一点"的参数也能用
工具参数常是 JSON,但模型经常写出不合法的 JSON(少个引号、多个逗号)。_safe_repair_json(parser.py:161)负责抢救:
# parser.py:161
def _safe_repair_json(tool_input: str) -> str:
if tool_input.startswith("[") and tool_input.endswith("]"):
return tool_input # ① 看着像数组 → 原样返回,不动
tool_input = tool_input.replace('"""', '"') # ② 三引号 → 单引号(模型常见笔误)
result = repair_json(tool_input) # ③ 调 json_repair 库尝试修复
if result in UNABLE_TO_REPAIR_JSON_RESULTS: # ④ 修出来是 '""' 或 '{}' 说明修坏了
return tool_input # → 放弃修复,返回原文
return str(result)
① 数组直接放行以 [ 开头 ] 结尾的多半是列表参数,json_repair 处理列表容易出错,干脆不碰、原样返回。② """ → "模型有时把字符串包成三引号(受 Python 影响),先统一成标准双引号。③ repair_json调第三方库 json_repair(parser.py:10 导入):能自动补引号、删多余逗号、闭合括号,把"差一点"的 JSON 修成合法的。④ UNABLE_TO_REPAIR 兜底★边界:如果修复结果是空 "" 或空 {}(constants.py:17 定义),说明原文烂到修不出有意义的东西——这时返回原文而不是空值,把判断权交给下游工具,别自作主张丢掉参数。💡 设计取舍②:为什么不直接要求模型输出合法 JSON、错了就报错?
最"干净"的做法是严格校验:JSON 不合法就抛错重来。但源码选择尽力修复。原因:文本 ReAct 面对的往往是没那么听话的模型(否则早用原生函数调用了)。对这些模型,"少个引号就整轮重来"太浪费——重来一次要多花一次 LLM 调用的钱和时间,而且模型下次未必写对。能自动修好的就修好,实在修不了才退回——这是"对不完美输入的宽容",是解析器健壮性的核心。代价是:修复偶尔会"猜错"模型的本意,所以设了 ④ 的兜底,不硬修。
L07
自愈闭环 + 边界 + 今日小结
把 Day 08 和今天串起来看,"解析失败→自愈"是一个完整闭环:
图注:parse 抛出的异常 → 变成给模型的提示 → 模型改正后重来。解析器和循环合作完成"自愈"。
⚠️ 边界:解析器无法处理"模型死活不听话"的死循环
自愈很美,但有个隐患:如果模型反复输出错误格式,每次都触发
OutputParserError → 提示 → 重来 → 又错……理论上会一直转。谁来兜底?答案是 Day 08 的 max_iter(默认 25)——解析器自己不管终止,它只负责"这一次翻译对不对";"翻译反复失败要不要停"是循环的职责。职责分离:parser 管解析,executor 管刹车。所以别指望解析器解决死循环,那是循环层的事。👶 小白:原生函数调用模式(Day 08 那条路)也用这个 parser 吗?
👨🏫 老师:基本不用。原生模式下模型直接返回结构化的 tool_calls,框架直接读字段,不需要从文本里正则抠。parser.py 主要服务文本 ReAct 那条路(老模型/不支持原生的模型)。所以它是"给不听话模型擦屁股"的模块——模型越强、越走原生,这个 parser 的戏份越少。但它依然是兜底不可少的一环。
🧠 今天你应该能回答
parse的三种结果分别是什么?(AgentAction / AgentFinish / 抛 OutputParserError)- 为什么 Final Answer 的检测优先于 Action?
- 正则里
\s*、\d*、非贪婪/贪婪、re.DOTALL各解决什么? - 为什么要
_clean_action去星号、_extract_thought去围栏? _safe_repair_json为什么"尽力修"而不是"错了就崩"?兜底条件是什么?- 解析失败如何变成"自愈"?谁负责终止死循环?
✋ 10 分钟动手
P=lib/crewai/src/crewai
sed -n '25,60p' $P/agents/parser.py # 两个数据类 + 异常
sed -n '62,128p' $P/agents/parser.py # parse 主流程
sed -n '131,180p' $P/agents/parser.py # 抠思考 / 清洗 / 修 JSON
cat -n $P/agents/constants.py # 正则与提示常量
# 亲手喂几种文本给 parse
python -c "
from crewai.agents.parser import parse
print(parse('Thought: 该查了\nAction: search\nAction Input: {\'q\':\'SF\'}'))
print(parse('Thought: 想好了\nFinal Answer: 18 度'))
try: parse('我随便说点啥,没格式')
except Exception as e: print('报错并提示:', str(e)[:40])
"
明日预告 · Day 10:Day 08 的执行器有一个"多轮内循环",而 CrewAI 还有一个单步执行器
step_executor.py——它服务"规划"场景,把一个计划里的单个步骤拿来执行。明天看它怎么在"单步"里也跑一个小的 LLM→工具→观察循环,以及它和今天的 parser、Day 08 的执行器如何复用同一套零件。