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) 是一个纯函数:吃一段字符串,吐一个 AgentActionAgentFinish;要是文本格式不合规范,就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

两个数据类 + 一个异常:解析的"出料筐"

解析结果只有两种类型,都是简单的 @dataclassparser.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 字符串,就是后面要"喂回给模型"的纠错提示。异常不只是报错,还是"教模型改正"的载体。
数据结构:parse 的输入与三种输出 输入:LLM 原始文本 str AgentAction tool / tool_input thought / text AgentFinish output(str/BaseModel) thought / text OutputParserError error = 纠错提示 (抛出,非返回)
图注:一进三出。前两个是正常返回值,第三个是异常——但它带着"怎么改"的信息,供循环自愈。
L03

parse:一段文本走哪条路

核心函数 parseparser.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.pyagents/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_thoughtparser.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_jsonparser.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_repairparser.py:10 导入):能自动补引号、删多余逗号、闭合括号,把"差一点"的 JSON 修成合法的。
④ UNABLE_TO_REPAIR 兜底★边界:如果修复结果是空 "" 或空 {}constants.py:17 定义),说明原文烂到修不出有意义的东西——这时返回原文而不是空值,把判断权交给下游工具,别自作主张丢掉参数。
💡 设计取舍②:为什么不直接要求模型输出合法 JSON、错了就报错? 最"干净"的做法是严格校验:JSON 不合法就抛错重来。但源码选择尽力修复。原因:文本 ReAct 面对的往往是没那么听话的模型(否则早用原生函数调用了)。对这些模型,"少个引号就整轮重来"太浪费——重来一次要多花一次 LLM 调用的钱和时间,而且模型下次未必写对。能自动修好的就修好,实在修不了才退回——这是"对不完美输入的宽容",是解析器健壮性的核心。代价是:修复偶尔会"猜错"模型的本意,所以设了 ④ 的兜底,不硬修。
L07

自愈闭环 + 边界 + 今日小结

把 Day 08 和今天串起来看,"解析失败→自愈"是一个完整闭环:

控制流:解析失败的自愈闭环 LLM 输出文本 parse() 格式不合规 抛 OutputParserError 循环把 error 当提示 append 进 messages (Day 08 的 handle_output_parser_exception) 模型看到提示 → 下一圈重新输出(更可能格式正确)
图注: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 的执行器如何复用同一套零件。
← Day 08 执行循环 Day 10 · 单步执行 →