Ch12 · 结构化输出与工具调用
覆盖提交:
8fe91c5(初版 Agent 骨架:advanced_rag_agent.py的_parse_react_output/_parse_plan_result与查询改写的内联解析)·dc7cf3b(LangGraph 改造:langgraph_rag_agent.py的_parse_classify/_parse_json_list)·6083d73(MCP 改造:mcp_server.py+skill_framework.py的工具 schema 与参数校验)·f006a70(harness:evalkit/judge.py的_extract_scores/_to_score)·dd70667(Bad Case 自动诊断:agentworkflow/rules.py的parse_conclusion/normalize_code与pipeline.py的_extract_bool_json)·5865c9f(语义路由 v2:intent_classifier.py的_parse_llm_intent) 代码位置:GitHub 搜索lingluo1hao / enterprise-ai→langgraph_rag_agent.py:2495_parse_classify·:2541_parse_json_list·advanced_rag_agent.py:1768_parse_react_output·:1926_parse_plan_result·intent_classifier.py:276_parse_llm_intent·evalkit/judge.py:108_extract_scores·agentworkflow/rules.py:187parse_conclusion·agentworkflow/pipeline.py:88_extract_bool_json·mcp_server.py(24 个@mcp.tool())·skill_framework.py:136validate_params难度:★★☆☆☆ 阶段:Day 1–28 关键词:结构化输出 / 软约束 / 四种脏输出 / 三层兜底 / tool schema 自动派生 / 描述漂移 / 参数校验器 / 契约 编号说明:本文件是 v2 课程表第 12 集("模型返回不是 JSON,整条链路就崩")。⚠️ 本集 ★新增——没有底稿(目录里ch12_BadCase与自动诊断.md是旧编号命名,对应 v2 的第 16 集,与本集无关)。正文素材是代码本身(本集没有 run 日志可用),逐条已核;编号一律以 课程表_第06集起.md 为准。
本章导读
上一章(Ch11 · 大模型调参与效果评测)解决的是"怎么知道检索变好了"。
本章要往回再退一步。往前数六集,每一个环节都默认了同一件事:
| 集 | 在做什么 | 默认前提 |
|---|---|---|
| Ch06 | 融合 dense + BM25 | —— |
| Ch07 | 检索 → 父块回填 → 出处 | —— |
| Ch08 | 批量向量化 | —— |
| Ch09 | 精排与去冗余 | —— |
| Ch10 | 权限下推 | —— |
| Ch11 | 评测与门禁 | 需要"判分结果"可解析 |
| Ch12 | ? | "上层能看懂模型说的话" |
这条前提,全书没有一处专门写过。 它一直靠"多写几行 try/except"维持着。
而"多写几行"这件事最麻烦的地方,不是它难——是它会被写很多遍,而且每一遍都不一样。
# 你在提示词里写了这一句,然后心里就踏实了
"请以 JSON 格式输出"
# 然后你需要处理它不听话的那一版
# 第一处:剥围栏 + loads + 正则兜底 + 规则兜底
# 第二处:剥围栏 + loads + 正则兜底
# 第三处:整段 loads + 抠花括号 + 逐行正则
# 第四处:只抠花括号
# 第五处:按行拆分,根本不解析 JSON
# …
本章要做的,就是把这条"默契",变成一份"契约"。
本章真正想让你带走的,是四个能在别处复用的判断:
- "我在提示词里让它输出 JSON 了"到底值多少钱? —— 它只是一句请求,不是一份保证。项目里最能说明这件事的,是有两处代码:提示词明令禁止围栏,解析函数仍然要剥围栏——写代码的人显然被坑过
- 模型违约时,你的代码该走哪条路? —— 走四步阶梯:剥围栏 → 直接
loads→ 正则抓{...}→ 给一个确定的兜底值。最后一层必须是"给值",不能是"抛异常" - 工具描述该写几遍? —— 一遍。
@mcp.tool()从类型注解 + docstring 自动派生 schema,这是"只有一份来源"的正确做法;而项目里同一份工具描述手写了第二遍,且已经和第一遍不一样了 - 校验器该有几份? —— 一份。项目里有 4 个手写校验/归一化器分散在四处,没有任何一处知道完整规则。而代价的另一面是诚实的:全项目没有 Pydantic、没有 jsonschema
还有一条原则要在本章兑现第六次。「静默降级比崩溃危险一百倍」是全书五条原则的第 2 条(见 README):
Ch05 判断某个降级不配存在,删掉了它; Ch06 判断某个降级可以留,但必须打日志; Ch09 的判断是——降级留不留是次要的,问题是"你怎么知道它降级了"; Ch10 的判断是——最危险的降级,是你从来没写过的那个; Ch11 的判断是——最危险的降级,是那个让指标"看起来还行"的降级; Ch12(本章)的判断是——最危险的降级,是那个"兜住了但兜歪了"的降级。
前五次讨论的是"降级要不要留、怎么发现、怎么度量"。本章讨论的是降级本身的形状:本章里有两类降级,都让程序不崩、都让链路继续走,但一类是对的(给一个语义正确的默认值),另一类是错的(把"字段缺失"静默吞掉,返回一个看起来正常的结果)。
本集不教算法,教一个习惯。 本章一个算法都没讲——剥围栏是
re.sub、解析是json.loads、兜底是return一个值。它的价值不在"教你会什么",而在"让你去看一眼你自己的项目":你有几处解析?几层兜底?几份描述?
本集学习目标
学完这一章,回到这张表逐一自查——四条,一条都不能少:
| # | 目标 | 达标标准 |
|---|---|---|
| 1 | 说清"模型会返回 JSON"为什么是错觉 | 能背出四种脏输出(纯 JSON / 带代码块 / 不完整 JSON / 完全非 JSON);能说出 P0-02 §五 的"软约束"意味着什么——JSON Mode 是"引导",不是"保证" |
| 2 | 会写"三层兜底阶梯" | 能按顺序说出四步(剥围栏 → 直接 loads → 正则抓 {...} → 兜底给一个值);能解释为什么最后一层必须给值、而不能抛异常;能说出为什么"重试一次"不是答案 |
| 3 | 能说清 tool schema 的两条来源 | 能说出 @mcp.tool() 的 schema 从哪来(类型注解 + docstring 自动派生)vs BaseSkill 的手写 description;能指出本项目同一份描述写了两遍且已经漂移;能解释复杂参数为什么用 JSON 字符串传 |
| 4 | 能判断"校验器该有几份" | 能说出"校验器分散 = 等于没有校验"的道理;能说清本项目**没有任何统一 schema(零 Pydantic / 零 jsonschema)**这个事实,以及它换来了什么、代价是什么 |
目标 1 是本集的去魅——很多人以为"我在 prompt 里让它输出 JSON 就行了",那只是把概率往上推了一点。 目标 2 是本集最能直接抄走的部分——它跟 RAG 无关、跟大模型也几乎无关,任何"解析外部字符串"的场景都用得上(爬虫返回体、第三方 SDK、日志行)。 目标 3 是本集通往 MCP(v2 第 19 集)的桥——
@mcp.tool()把"参数长什么样"交给了类型注解和 docstring,这是"schema 只有一份来源"的正确做法。 目标 4 是本集最有争议、也最有价值的一条——项目里没有 Pydantic。这不是"省略",这是一个要付代价的选择,本集会诚实地说清代价在哪。
📐 理论基石:结构化输出只有三档保证(P0-02 §五「结构化输出约束」)
先看 P0-02 §五 那张表,它把"让模型输出 JSON"这件事的靠谱程度分了三级:
| 手段 | 机制 | 保证级别 |
|---|---|---|
| JSON Mode | 模型被引导输出合法 JSON | 软(仍可能格式漂移) |
| Function Calling | 模型输出工具调用的结构化参数 | 软 |
| Grammar-constrained decoding | 解码时直接屏蔽非法 token(如 outlines / llama.cpp grammar) | 硬(数学保证可解析) |
P0-02 给了个很准的程序员类比:
JSON Mode 像"建议你按 schema 写";grammar decoding 像"编译器在词法分析阶段就拒绝非法字符"——后者从根上不可能产出解析失败的结果。
以及那句工程含义(P0-02 §五 末句):
抽字段、调 API 这类要确定性解析的任务,优先 grammar decoding 或后置校验,别只信 JSON Mode。
P0-02 §六「幻觉的提示层缓解」把这层意思说得更狠:
提示能从"分布层面"降低幻觉,但它是软约束……但:提示约束能被模型在长生成中「遗忘」或越界。
为什么"软"这个字要单独讲一段
"软"不是"效果差一点",是"性质不同"。
| 硬保证(grammar decoding) | 软约束(提示 / JSON Mode) | |
|---|---|---|
| 失败的可能性 | 零(词法层面就不许出现非法 token) | 永远非零——你可以把概率推到 0.99,但没有一步把它变成 1 |
| 你怎么知道它生效了 | 不需要知道,结构上不可能违规 | 只能靠后置校验(解析一次才算数) |
| 代码该长什么样 | 直接 json.loads,不写 except 也不心慌 | 必须有一套兜底阶梯,且每一层的降级都要有日志 |
所以"软"意味着一个具体的工程后果:只要走软路,你的解析函数就永远不可能"写完"。 这不是代码没写好,这是约束的性质决定的。
为什么本项目只能走软路(四步因果链)
这不是"图省事",是被约束推着走的:
① 数据不出内网
↓
② 只能用本地模型(Ollama)+ 自建网关链路
↓
③ 这条本地链路拿不到 grammar-constrained decoding 这类"硬保证"
↓
④ 于是只剩"提示约束 + 后置校验"这条软路
第 ③ 步值得说清:grammar decoding 需要推理侧(服务端)支持——P0-02 §五 举的是 outlines / llama.cpp grammar 这类方案。本项目的链路是"自建网关 → 本地 Ollama",模型与采样参数都在自己手里,但那层"解码期屏蔽非法 token"的能力不在链路上。 拿不到,就只剩后置校验。
这条因果链是本章所有设计正当性的来源。 后面每一节的"为什么必须这么防",最后都能挂回第 ④ 步:因为我们拿不到硬保证,所以只能把"防"写在解析侧。
工具描述的另一半:P0-05 §四「工具调用协议」
本章后半场讲 tool schema。它的理论出处是 P0-05 §四,那里给的是一段 JSON:
{
"name": "query_order",
"description": "根据订单号查询订单状态",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号"}
},
"required": ["order_id"]
}
}
P0-05 §四 的类比一句话点透:
工具描述 = 函数的「类型签名 + docstring」;模型 = 根据签名选函数、填参数;运行时 = 真正执行。
记住这句,第三部分会反复用它:既然 schema 的内容本来就是"类型签名 + docstring",那它就不该被手抄第二遍。
第一部分 · 为什么"它会返回 JSON"是错觉
1.1 一句"请以 JSON 格式输出",值多少钱
先看本项目里最重的一句提示词——判分提示词(evalkit/judge.py:58-71):
_SYSTEM = (
"你是一名严谨的 RAG 系统评审专家。……\n"
"你的任务是从两个维度给答案打分(0~1 浮点数,越大越好):\n"
...
"严格遵守:\n"
"- 只输出一个 JSON 对象,不要任何额外解释、不要 markdown 代码块标记。\n"
"- 字段:faithfulness(数字), relevancy(数字), reason(字符串,≤40字中文说明扣分点)。\n"
"示例:{\"faithfulness\":0.85,\"relevancy\":0.9,\"reason\":\"第3点数值上下文无出处\"}"
)
evalkit/judge.py:58-71。注意它写了三层保护:
- "严格遵守"——语气上加重;
- "只输出一个 JSON 对象,不要任何额外解释、不要 markdown 代码块标记"——把最常见的两种违约点名禁止;
- 一个示例——few-shot,把格式直接示范一遍。
这三层加起来,就是这个项目里能做到的最强"软约束"。
那它够不够?看紧挨着的那个解析函数(evalkit/judge.py:108-148)的第一行注释:
def _extract_scores(text: str) -> Dict[str, Optional[float]]:
"""
从 judge 返回里解析分数,兼容严格 JSON 与偶尔夹带文字的情况。
返回 {"faithfulness","relevancy","reason"},解析失败的项为 None。
"""
"兼容严格 JSON 与偶尔夹带文字的情况"——这句话就是答案:写提示词的人知道它不够。
1.2 项目自己的 docstring 列了四种脏输出
比判分提示词更直白的,是 langgraph_rag_agent.py:2495-2511 那段 docstring。它把"模型可能怎么不听话"列成了一张清单:
def _parse_classify(self, result: str, fallback_query: str):
"""
【辅助:解析 classify 节点的 LLM 输出】
作用:把 LLM 返回的 JSON 字符串解析为 (问题类型, 消解后问题)。
原理:
LLM 的输出格式不可靠,可能包含:
- 纯 JSON:{"type": "simple", "resolved": "心跳间隔是多少?"}
- JSON 带代码块:```json\n{...}\n```
- 不完整 JSON:{...(缺少尾部花括号)
- 完全非 JSON:普通文本
容错策略(三层兜底):
1. 直接 json.loads:正常情况,一步成功
2. 正则提取花括号内的内容再解析:处理带代码块标记的情况
3. 完全失败 → 回退到规则分类 + 原问题作为消解结果
"""
langgraph_rag_agent.py:2496-2518(原文)。这段 docstring 是本章的地基,它一次给出了两样东西:
| 它给出了什么 | 内容 |
|---|---|
四种脏输出(:2502-2506) | ① 纯 JSON ② 带代码块围栏 ③ 不完整 JSON(缺尾部花括号) ④ 完全非 JSON |
三层兜底策略(:2508-2511) | ① 直接 json.loads ② 正则抓花括号 ③ 回退规则分类 |
请特别看待这四种脏输出的分布——它不是理论推演,是"被坑过的清单":
① 纯 JSON ← 正常情况(你期待的)
② 带代码块 ← 提示词里明说了"不要 markdown 标记",它还是给了
③ 不完整 JSON ← 最隐蔽:前 90% 都是对的,只是少了一个 }
④ 完全非 JSON ← 最离谱:JSON 后面还跟你聊了一句
本章的第一个反直觉判断: 这四种里,真正危险的不是 ④,是 ③。 ④ 一眼就看出不对,你会立刻去查;③ 是"前 90% 都对"——如果你只用"看起来像不像 JSON"来判断,它会骗过你的眼睛,然后在某一行代码上炸掉。
1.3 最刺眼的一处:提示词明令禁止,代码仍然要防
现在看本项目里最能证明"软约束不可信"的一处代码。查询改写的提示词(advanced_rag_agent.py:1342-1343):
输出格式(必须是合法JSON,不要包含```json等markdown标记):
{{"rewrites": ["关键词组合1", "关键词组合2"]}}"""
提示词写得很明确:必须是合法 JSON,不要包含 ```json 等 markdown 标记。
然后看解析它的那几行(advanced_rag_agent.py:1345-1362):
result = self.llm.chat(system_prompt, query, task="rewrite", user=self.user)
# 清理可能存在的 markdown 标记
result = result.strip()
if result.startswith("```"):
result = re.sub(r'^```(?:json)?\s*', '', result)
result = re.sub(r'\s*```$', '', result)
data = json.loads(result)
rewrites = data.get("rewrites", [query])
提示词刚说了"不要 markdown 标记",下一行就开始 re.sub(r'^```(?:json)?\s*', '', result) 剥围栏。
同样的"打脸"在本项目出现了两处独立的地方:
| 位置 | 提示词怎么说的 | 解析代码怎么做的 |
|---|---|---|
advanced_rag_agent.py:1342 vs :1349-1351 | "必须是合法JSON,不要包含 ```json 等 markdown 标记" | if result.startswith("```"): re.sub(r'^```(?:json)?\s*', ...) |
evalkit/judge.py:68 vs :128-131 | "不要 markdown 代码块标记" | 退路 1:m = re.search(r"\{.*\}", raw, re.DOTALL) —— 从夹带的文字里抠出 JSON |
本章最重要的一个判断: 这不是"代码写得啰嗦",这是"写代码的人被真实坑过"。 如果提示词真的管用,这两处
re.sub就是永远不会执行的死代码——而它们显然不是被当成死代码写下的。 所以:当你在 code review 里看到"提示词已经禁止了、代码还在防"的时候,正确的反应不是删掉那几行,是去问一句"当初是怎么坏的"。
1.4 为什么"重试一次"不是答案
被坑过之后,最自然的反应是:"那我发现格式不对,就再问它一次。"
这个想法在本项目里一处都没有实现。先看事实(这是本章的必查项之一,因为课程表写的是"解析失败重试"):
$ grep -rn "json.JSONDecodeError" --include=*.py .
./advanced_rag_agent.py:1359: except (json.JSONDecodeError, Exception) as e:
./advanced_rag_agent.py:1954: except json.JSONDecodeError:
./evalkit/schema.py:531: except json.JSONDecodeError as e:
./intent_classifier.py:284: except (json.JSONDecodeError, TypeError):
./langgraph_rag_agent.py:2528: except (json.JSONDecodeError, TypeError):
./langgraph_rag_agent.py:2535: except (json.JSONDecodeError, TypeError):
./langgraph_rag_agent.py:2575: except (json.JSONDecodeError, TypeError):
./langgraph_rag_agent.py:2584: except (json.JSONDecodeError, TypeError):
./tests/test_harness_grading.py:90: except json.JSONDecodeError:
9 处命中,逐处看 except 后面是什么:7 处是"解析 LLM 输出"的,全部走"兜底降级"——return [query] / return [SubTask(...)] / return None / return out;另外 2 处不算(evalkit/schema.py:531 在读 jsonl 文件、tests/ 里是用例)。
没有一处重新调用 LLM。
这不是偷懒,是三个理由叠在一起:
理由一:重试的收益是"概率的二次方",成本是"延迟和钱包的线性"
一次成功的概率 p = 0.95
重试一次 → 0.95 + 0.05 × 0.95 = 0.9975 ← 看着很美
但它不解决"系统性违约":
如果模型是"被要求输出 JSON 时习惯性包一层围栏",
那它的第二次输出,大概率还是包着围栏的。
—— 你花了两倍延迟、两倍 token,买到的是一次"同样的失败"。
更关键的是:那 5% 的失败率不是随机分布的。 它集中在"长输出、复杂 schema、嵌套字段"这些地方——恰恰是你最需要它成功的地方。重试在这类场景下最不划算。
理由二:兜底的价值是"链路不断",重试的价值是"这次更准"——两者不可替代
| 重试 | 兜底 | |
|---|---|---|
| 保证的是 | 这一次可能更准 | 整条链路一定继续走 |
| 失败时的代价 | 又脏一次,而且你还得再写一套兜底 | 语义降级(答案质量下降),但用户拿到的是答复,不是错误页 |
| 复杂度 | 需要循环、需要上限、需要区分"值得重试"与"不值得" | 一个 return |
理由三(本项目特有):兜底给了"可归因"的位置
看 _parse_classify 的第三层(langgraph_rag_agent.py:2538-2539):
# 第三层:全部失败 → 规则分类兜底
return self._quick_classify(fallback_query), fallback_query
它回退到的是一个规则分类器(_quick_classify,:2455-2493)——4 条规则、明确、可测试。这意味着"LLM 解析失败"这件事,被转化成了一个有确定行为的分支:问题类型由规则定,消解后的问题就是原问题。
能测的分支,好过"再试一次"这种概率游戏。
⚠️ 一个必须点破的命名陷阱:本项目里有个提示词叫
rewrite_retry(langgraph_rag_agent.py:1860)。名字看着像"解析失败重试",它跟解析失败毫无关系——它是第二轮检索的换角度改写提示词:if prev_docs is None: # 第 1 轮:正常改写 prompt = self.pm.get_prompt("rewrite_first") else: # 第 N 轮:基于之前的检索结果,换角度改写 prompt = self.pm.get_prompt("rewrite_retry")
langgraph_rag_agent.py:1848-1866。"retry" 指的是"再检索一轮",不是"再问模型一次"。 这个名字是本集的"教学级陷阱":如果你只按名字找代码,会得出一个完全错误的结论。
1.5 四种脏输出,各长什么样
把 1.2 那张清单展开成可对照的形式,方便你在自己项目里对号入座:
| # | 脏输出形态 | 长什么样 | 会怎么坏 |
|---|---|---|---|
| ① | 纯 JSON | {"type": "simple", "resolved": "心跳间隔是多少?"} | 不坏——这是你期待的 |
| ② | 带代码块围栏 | ```json\n{"type":"simple"}\n``` | json.loads 直接抛 JSONDecodeError(首字符是反引号) |
| ③ | 不完整 JSON | {"type": "simple", "resolved": "心跳间隔是 | 最危险:前面全对,少了尾部 } → 抛错,但肉眼扫过去像是对的 |
| ④ | 完全非 JSON | {"type": "simple"}\n以上是我的判断 | 首字符没问题、结尾多了一句 → 抛错位置在末尾,报错信息很容易被误读成"JSON 内部有问题" |
一个反直觉的点:②④ 都很显眼,③ 最不显眼。所以在写兜底的时候,"剥围栏"这一步其实解决的是最容易发现的问题;真正需要"正则抓 {...}"那一层来救的,是 ④(JSON 后面多一句)——因为只有"从文本里抠出第一个 {...} 块"这招,才能同时把 ② 的前缀和 ④ 的后缀一起切掉。
第一部分小结 · 对照自查
- 我能说出四种脏输出,并指出哪一种最危险、为什么
- 我能说出 P0-02 §五 的三档保证级别,并解释"软"为什么不是"效果差一点"而是"性质不同"
- 我能复述四步因果链(数据不出内网 → 本地链路 → 无 grammar decoding → 只剩软路 + 后置校验)
- 我能举出本项目**两处"提示词禁止了、代码还在防"**的位置
- 我能说出三个理由说明"解析失败就重试"为什么不划算
- 我知道
rewrite_retry不是解析重试(它是第二轮检索的改写提示词)
如果第 2、5 条说不顺,回头再看 §「理论基石」的"软"字那一段与 §1.4;如果第 6 条记不住,记住一句话就够:"名字里有 retry,不代表它 retry 的是你以为的那件事。"
第二部分 · ★ 三层兜底阶梯
2.1 四步阶梯:把 docstring 变成代码
_parse_classify 的 docstring 说了"三层兜底",代码里是这么落地的(langgraph_rag_agent.py:2519-2539,原文):
result = result.strip()
# 清理可能的 Markdown 代码块标记
result = re.sub(r"```(?:json)?\s*", "", result)
result = result.replace("```", "").strip()
try:
# 第一层:直接解析
data = json.loads(result)
return data.get("type", "simple"), data.get("resolved", fallback_query)
except (json.JSONDecodeError, TypeError):
# 第二层:正则提取花括号内容再解析
match = re.search(r'\{.*\}', result, re.DOTALL)
if match:
try:
data = json.loads(match.group())
return data.get("type", "simple"), data.get("resolved", fallback_query)
except (json.JSONDecodeError, TypeError):
pass
# 第三层:全部失败 → 规则分类兜底
return self._quick_classify(fallback_query), fallback_query
对照着看,它是一个四步的动作序列:
| 步 | 动作 | 代码 | 它防的是什么 |
|---|---|---|---|
| 前置 | 剥围栏 | re.sub(r"```(?:json)?\s*", "", result) | ② 带代码块 |
| 1 | 直接 loads | data = json.loads(result) | 正常情况(① 纯 JSON),一步成功 |
| 2 | 正则抓 {...} 再 loads | re.search(r'\{.*\}', result, re.DOTALL) | ④ JSON 前后多话(也顺带覆盖 ②③ 的一部分) |
| 3 | 兜底给值 | self._quick_classify(fallback_query), fallback_query | ③ 不完整 JSON 与一切没救的情况——链路继续走 |
一个必须说清的用词:代码 docstring 自称"三层兜底",本章却说"四步阶梯"——两个说法都对,量的是不同的东西。 "三层"数的是兜底分支(
loads/ 正则 / 规则),"四步"数的是"从原始字符串到可用结构,一共做过几个动作"(剥围栏也是动作)。 为什么要把"剥围栏"单列一步?因为它不是兜底、是归一化:它在try之外,无论后面走哪条路都要先执行。这个区别在本章很实用——归一化是第一步,兜底才是阶梯。
2.2 ★ 十处手写:一张全表
这是本章最重要的一张表。 本项目把"模型输出 → 可用结构"这件事,在十个地方各写了一遍。
先给可复现的命令(两条都要跑,原因见下面的⚠️):
# 第一条:抓 def _parse_* / def parse_* —— 命中 12 行,其中 6 行是"解析 LLM 文本输出"
grep -rn "def _parse\|def parse_" --include=*.py . | grep -v __pycache__
# 第二条:抓 def _extract_* —— 命中 5 行,其中 2 行是"解析 LLM 文本输出"
grep -rn "^def _extract_\|^ def _extract_" --include=*.py . | grep -v __pycache__
第一条的 12 行里,6 行是"解析 LLM 文本输出"(_parse_classify · _parse_json_list · _parse_llm_intent · _parse_react_output · _parse_plan_result · parse_conclusion),另外 6 行不算:
| 不算的那 6 行 | 它解析的是什么 |
|---|---|
advanced_rag_agent.py:781 _parse_hits | Milvus 返回记录 |
agentworkflow/rules.py:168 parse_tenant | 用户点踩时写的诊断文本 |
evalkit/runner.py:63 _parse_overrides | 命令行 argv |
llm_gateway.py:202 _parse_config_file | 配置文件 |
scripts/contract_check.py:51 _parse_ddl_cols | DDL 文本 |
scripts/eval_retrieval_bury.py:44 _parse_hits | Milvus 返回记录 |
⚠️ 第一条命令抓不到 _extract_* —— evalkit/judge.py:108 _extract_scores 与 agentworkflow/pipeline.py:88 _extract_bool_json 这两处,必须用第二条单独抓(第二条另外 3 行是图表抽取:ingest/chunk.py:156 / ingest/loaders.py:602 / langgraph_rag_agent.py:513,与 LLM 输出无关)。
具名 8 处 = 6(_parse_*)+ 2(_extract_*),再加 2 处内联(advanced_rag_agent.py:1353 与 langgraph_rag_agent.py:1870 的查询改写解析)→ 合计 10 处。
现在看这十处的全貌(层数口径 = "提取策略的尝试次数",剥围栏是前置归一化、不计层):
| # | 站点 | 位置 | 范式 | 层数 | 最后一层给什么 |
|---|---|---|---|---|---|
| 1 | _parse_classify | langgraph_rag_agent.py:2495-2539 | JSON 派 | 3 | _quick_classify() 的规则分类 + 原问题 |
| 2 | _parse_json_list | langgraph_rag_agent.py:2541-2588 | JSON 派 | 3 | [{"id": 1, "task": 原问题}] |
| 3 | _parse_llm_intent | intent_classifier.py:276-292 | JSON 派 | 2 | None(把决定权交回上层) |
| 4 | _extract_scores | evalkit/judge.py:108-148 | JSON 派 | 4 | 三个字段全 None |
| 5 | _parse_react_output | advanced_rag_agent.py:1768-1825 | 正则逐字段派 | —— | 把整段输出当最终答案用 |
| 6 | _parse_plan_result | advanced_rag_agent.py:1926-1957 | JSON 派(无正则兜底) | 1 | [SubTask(单任务)] |
| 7 | parse_conclusion | agentworkflow/rules.py:187-208 | 正则抓块派 | 1 | 空结构:code=None |
| 8 | _extract_bool_json | agentworkflow/pipeline.py:88-108 | 正则抓块 + 关键词退路 | 2 | {"relevant": None, "gap": ""} |
| 9 | 查询改写(内联) | advanced_rag_agent.py:1353-1362 | JSON 派(有剥围栏、无正则) | 1 | [原问题] |
| 10 | 查询改写(内联) | langgraph_rag_agent.py:1868-1874 | 按行拆分派 | 0(根本不解析 JSON) | 按行取前 3 + 把原问题插到第 0 位 |
读完这张表的第一个反应应该是:层数从 0 到 4,写法四种,没有一个共用函数。
这不是"某个人偷懒"。它是十次独立决策的累积:每次遇到一个新的"要让模型吐结构"的场景,就地写一个解析函数——因为在那一点上,这是最省事、最贴合当时需求的做法。
本章的第二个反直觉判断: 问题不是"写得太糙",是"每写一次都是在重新决定一遍同一件事"。 第 1 处和第 2 处的三层层数、兜底策略完全一致(因为它们由同一个人在同一个文件里写下),但第 3 处就少了一层、第 6 处没有正则兜底、第 10 处干脆不解析 JSON。 十个各自为政的默契,不叫约定。
2.3 四种范式,没有一种共享代码
把那十处按"用什么手段把文本变成结构"分堆,正好四种:
① JSON 派 剥围栏 → json.loads →(有的加)正则抓 {...} → 兜底
_parse_classify · _parse_json_list · _parse_llm_intent · _extract_scores · _parse_plan_result
· 改写解析(advanced_rag_agent.py 那份)
② 正则逐字段派 按 "Thought:/Action:/Action Input:/Final Answer:" 逐字段抓
_parse_react_output(advanced_rag_agent.py:1768)
③ 正则抓块派 只做 re.search(r'\{.*\}') → loads,没有"先整段 loads"
parse_conclusion(rules.py:187)· _extract_bool_json(pipeline.py:88)
④ 按行拆分派 完全不走 JSON:split("\n") → 去编号 → 取前 N 行
改写解析(langgraph_rag_agent.py:1870)
第 ② 种值得单独看一眼——它是"用格式约定代替 JSON"的典型(advanced_rag_agent.py:1783-1815):
# 提取 Thought
thought_match = re.search(r'Thought[::]\s*(.+?)(?=\n(?:Action|Final|最终)|$)', output, re.DOTALL)
...
# 检查是否是最终答案
final_match = re.search(r'Final Answer[::]\s*(.+)', output, re.DOTALL)
...
# 也检查中文 "最终答案"
final_match_cn = re.search(r'最终答案[::]\s*(.+)', output, re.DOTALL)
注意三件事,它们都是"被坑过"的痕迹:
- 中英文双写法:
Thought[::]用字符类同时兼容半角与全角冒号;Final Answer与最终答案各写一条——因为模型两种都给过; (?=\n(?:Action|Final|最终)|$)这个前瞻:靠"下一个字段名"来界定当前字段的结束——这是在没有分隔符的文本里做边界判断;:1810还有一行清理:
# 清理 action 中的多余描述(如 "doc_search(文档检索)" → "doc_search")
step.action = re.split(r'[((]', step.action)[0].strip()
注意最后这个"兜底"的形状——它不是"给一个默认值",而是把多出来的东西切掉。这是同一件事的另一种解法:模型多说了话,就把话剪掉。
四种范式没有优劣,只有"匹配不匹配场景":
- 结构化程度高、字段固定 → JSON 派(最好解析、最好校验);
- 输出是"思考过程 + 动作"这种半结构化文本 → 正则逐字段派(本来就是给它写 ReAct 的);
- 只关心"里面有没有一个 JSON 块" → 正则抓块派(最省事,但也最不严);
- 输出本来就是一份清单(一行一个关键词) → 按行拆分派(见 2.4)。
但四种范式分散在十处、各自实现,这本身就是问题:你无法回答"我们项目的解析到底防到什么程度"这个问题。
2.4 ★ 同一件事,两套约定:查询改写的两种解析
十处站点里,有两处干的是同一件事——解析"查询改写"的结果。它们的要求完全不同。
第一份要求输出 JSON(advanced_rag_agent.py:1342-1354):
输出格式(必须是合法JSON,不要包含```json等markdown标记):
{{"rewrites": ["关键词组合1", "关键词组合2"]}}"""
...
data = json.loads(result)
rewrites = data.get("rewrites", [query])
第二份根本不用 JSON,直接按行拆(langgraph_rag_agent.py:1868-1874):
# 解析 LLM 输出:按行拆分,取前 3 个非空行
# 去「1. 2. 3.」或「1、2、3、」编号噪声(LLM 常给有序列表,编号会干扰检索)
queries = [re.sub(r"^\d+[.、]\s*", "", q.strip())
for q in result.strip().split("\n") if q.strip()][:3]
# 兜底:始终保留原始问题,且置于列表首位(原句是最高精度信号,
# 放末尾会被改写 query 抢注分数而沉底;见改造方案 §4.1)
queries.insert(0, query.strip())
return queries
同一个项目、同一个动作(把 LLM 的改写结果变成一组搜索词)、两种约定。
advanced_rag_agent.py 那份 | langgraph_rag_agent.py 那份 | |
|---|---|---|
| 对模型的格式要求 | JSON:{"rewrites": [...]} | 一份清单:一行一个词 |
| 提示词怎么说 | "必须是合法JSON,不要包含 ```json 等 markdown 标记" | (见对应提示词模板 rewrite_first / rewrite_retry,按行给) |
| 解析手段 | json.loads + data.get("rewrites", [query]) | split("\n") + 去编号 + 取前 3 |
| 脏输出的表现 | 抛 JSONDecodeError → 整条改写作废,只剩原问题 | 不抛错——多给一行就多一个搜索词,少给一行就少一个 |
| 解析失败时 | return [query](1 个搜索词) | 不存在"解析失败"这个状态 |
| 兜底动作 | 把原问题 insert(0, ...) 进 JSON 结果里 | 把原问题 insert(0, ...) 进列表首位 |
两者最后都做了同一件事:把原问题插到最前面(advanced_rag_agent.py:1356-1357 / langgraph_rag_agent.py:1874)—— 因为原句是最高精度的信号,放末尾会被改写词抢注分数而沉底(这句话项目自己在 :1872-1873 写明了)。
但"解析失败"这件事的后果完全不同:
JSON 派:格式一坏 → 改写全部作废 → 退化成"只用原问题"
(代价:这一轮检索的召回变差,但链路活着)
按行派:格式无所谓 → 多一行/少一行都能用
(代价:万一模型把吐槽也写进某一行,那一行会变成一个真实的搜索词)
本章的第三个反直觉判断: "按行拆分"看起来更脆弱,其实更稳;"要求 JSON"看起来更严谨,其实更脆。 因为约束越强,违约的代价越大——你把格式要求提到 JSON,模型违约时你就从"少一个搜索词"变成了"整条改写作废"。 所以"该不该要求 JSON"不是审美问题,是一道成本题:你需要 JSON 里的多个字段、需要嵌套、需要类型区分时,才值得付这份违约成本。
这也解释了为什么十处站点不能收敛成一个函数——它们对"违约时怎么办"的要求根本不一样:有的要"给个空值继续",有的要"退化成规则",有的要"返回 None 让上层决定"。这也是本章 5.2 会给出"改进项"而不是"标准答案"的原因。
2.5 层数最多的那一处:_extract_scores 的四层退路
evalkit/judge.py:108-148(原文):
def _extract_scores(text: str) -> Dict[str, Optional[float]]:
"""
从 judge 返回里解析分数,兼容严格 JSON 与偶尔夹带文字的情况。
返回 {"faithfulness","relevancy","reason"},解析失败的项为 None。
"""
out: Dict[str, Optional[float]] = {"faithfulness": None,
"relevancy": None, "reason": ""}
raw = text.strip()
# 先尝试整段直接 json.loads
try:
obj = json.loads(raw)
if isinstance(obj, dict):
out["faithfulness"] = _to_score(obj.get("faithfulness"))
out["relevancy"] = _to_score(obj.get("relevancy"))
out["reason"] = str(obj.get("reason") or "")
return out
except Exception:
pass
# 退路 1:从文本里抠第一个 {...} 块
m = re.search(r"\{.*\}", raw, re.DOTALL)
if m:
try:
obj = json.loads(m.group(0))
if isinstance(obj, dict):
...
return out
except Exception:
pass
# 退路 2:逐行抓 "faithfulness" / "relevancy" 后的数字
for key in ("faithfulness", "relevancy"):
km = re.search(rf"{key}\D*?([0-1](?:\.\d+)?)", raw, re.IGNORECASE)
if km:
out[key] = float(km.group(1))
rm = re.search(r"reason[\"']?\s*[::]\s*[\"']?([^\"'\n]+)", raw, re.IGNORECASE)
if rm:
out["reason"] = rm.group(1).strip()
return out
(中间 return out 的重复赋值段与第一层相同,为省篇幅省略——完整原文见仓库。)
四层,一层比一层"不挑":
| 层 | 手段 | 对格式的要求 |
|---|---|---|
| 0 | 剥围栏(这一处没有——它直接从 text.strip() 开始) | 严格 JSON(不许有任何前后缀) |
| 1 | 整段 json.loads | 严格 JSON |
| 2 | re.search(r"\{.*\}") 抠块 | JSON 前后可以有话 |
| 3 | 逐行正则抓数字 rf"{key}\D*?([0-1](?:\.\d+)?)" | 连 JSON 都不需要——只要能找到 faithfulness 后面跟着的 0~1 数字 |
| 4 | 三个字段全 None | 什么都不需要 |
第 3 层是这一处的"绝招",它和结构化解耦到了极致:
faithfulness: 0.85 ← 能被抓到
忠实度 0.85 ← 抓不到(键名必须是那个英文词)
"faithfulness"(0.85) ← 能被抓到(\D*? 允许中间有非数字字符)
faithfulness: 85% ← 抓到的是…… 0.85?不,是 "85" 里的 "8"(见下)
⚠️ 最后一行是这一层的真实边界,必须说清:正则 ([0-1](?:\.\d+)?) 只匹配 0 或 1 开头的数——85 只会匹配到 8,然后 float("8") 得到 8.0,再被 _to_score 的 clamp 夹成 1.0。也就是说:模型如果说"85 分",这一层会把它变成 1.0 分。
这不是"bug",是这一层的设计边界:它只在"模型给了 0~1 之间的小数但夹在文字里"这一种情况下正确。超过这个范围,它给出的是一个"看起来合理"的错误值。
这正是本章那句"最危险的降级,是那个兜住了但兜歪了的降级"的第一手证据。 第 3 层的存在让解析永远不会失败——于是
faithfulness永远有一个 0~1 的值。 而**"永远有值"这件事,会掩盖"这一轮判分其实没解析出来"这个事实**。 怎么补救? 看_to_score(下一小节)与判分调用处(evalkit/judge.py:208)—— 是否把"某字段为None"当成一次可观测事件记下来,是这一处设计好坏的关键。
2.6 层数最少的两处:一层就够,但"够"的理由不同
第一处:parse_conclusion(agentworkflow/rules.py:187-208)——只有正则抓块
def parse_conclusion(text: str) -> Dict[str, Any]:
"""
解析 ReAct 探查的 Final Answer。
期望 JSON:{"code":"R1"|"R5"|...|null, "evidence":"...", "suggestion":"..."}
解析失败 → code=None(上层据此「探查完成但未归因,转人工」,不硬猜)。
"""
out: Dict[str, Any] = {"code": None, "evidence": "", "suggestion": ""}
raw = (text or "").strip()
m = _CONCLUSION_RE.search(raw) # _CONCLUSION_RE = re.compile(r"\{.*\}", re.DOTALL)
obj = None
if m:
try:
obj = json.loads(m.group(0))
except Exception:
obj = None
if not isinstance(obj, dict):
return out
out["code"] = normalize_code(obj.get("code"))
out["evidence"] = str(obj.get("evidence") or "")[:500]
out["suggestion"] = str(obj.get("suggestion") or "")[:500]
return out
它没有"先整段 loads"这一层,直接上正则抓块。理由是它面对的文本天然带前后缀——ReAct 的 Final Answer: 后面跟着的本来就是一段话,JSON 只是其中的一块。所以"整段 loads"在这里必然失败,写了也是死代码。
它的兜底很干净:解析不出来 → code=None → 上层据此判定"探查完成但未归因,转人工"(这句话写在 docstring 里,:192)。这是"兜底给值"的最好示范:给的不是"一个猜出来的值",而是一个明确的"没归因"状态。
第二处:_extract_bool_json(agentworkflow/pipeline.py:88-108)——抓块 + 关键词退路
def _extract_bool_json(text: str) -> Dict[str, Any]:
"""宽松解析 probe_docs 的 JSON 输出:{"relevant":bool,"gap":"..."}"""
import json as _json
import re as _re
out: Dict[str, Any] = {"relevant": None, "gap": ""}
m = _re.search(r"\{.*\}", text or "", _re.DOTALL)
if m:
try:
obj = _json.loads(m.group(0))
if isinstance(obj, dict):
out["relevant"] = obj.get("relevant") if isinstance(obj.get("relevant"), bool) else None
out["gap"] = str(obj.get("gap") or "")[:200]
return out
except Exception:
pass
# 退路:文本里找 是/否 判定
if _re.search(r"是|true|yes|能回答", (text or ""), _re.IGNORECASE):
out["relevant"] = True
elif _re.search(r"否|false|no|无法回答|不能", (text or ""), _re.IGNORECASE):
out["relevant"] = False
return out
它有一个别的站点都没有的东西(:98):
out["relevant"] = obj.get("relevant") if isinstance(obj.get("relevant"), bool) else None
isinstance(..., bool) 这一步就是"类型校验"。对照第 1 处(_parse_classify)的做法:
第 1 处 _parse_classify | 第 8 处 _extract_bool_json | |
|---|---|---|
| 取值 | data.get("type", "simple") | obj.get("relevant") if isinstance(..., bool) else None |
| 类型/合法性检查 | 没有 | 有(必须真是 bool) |
| 值非法时 | 照单全收,交给下游 | 变成 None(明确的"未知") |
这就是同一件事的两种态度。 第 8 处做了对的事,第 1 处没做——而"没做"的后果在下一节会看到。
2.7 ★ 兜底为什么必须"给一个值"——以及"给错值"长什么样
先把"必须给值"这条讲完。十处站点的兜底动作摆在一起:
| 站点 | 失败时给什么 | 给出去的那个值,语义对不对 |
|---|---|---|
_parse_classify | _quick_classify(原问题) + 原问题 | ✅ 规则分类器能给一个真实可用的类型 |
_parse_json_list | [{"id": 1, "task": 原问题}] | ✅ 一个子任务 = 不做拆解,语义正确 |
_parse_llm_intent | None | ✅ 明确的"我没判断出来",上层有降级路径(_lexical_or_default) |
_extract_scores | 三字段全 None | ✅ 但下游要负责把 None 当异常看(否则就变成 2.5 说的"兜歪了") |
_parse_react_output | 整段输出当 Final Answer | ⚠️ 语义可疑("没解析出动作" ≠ "这是最终答案"),但链路不死 |
_parse_plan_result | [SubTask(单任务)] | ✅ 不拆解,语义正确 |
parse_conclusion | {"code": None, ...} | ✅ "未归因"是明确状态,上层转人工 |
_extract_bool_json | {"relevant": None, ...} | ✅ 三值逻辑:True/False/None(未知) |
| 改写(adv) | [原问题] | ✅ 退化成"不改写" |
| 改写(lg) | —— | ——(它没有失败态) |
它们全部选择了"给一个值",没有一处 raise。 理由在 1.4 已讲(链路不断),这里补一条更根本的:
解析失败不是"异常",是"一种正常结果"。 模型输出不合格式,在你的系统里本就是预期会发生的事(否则你不需要写兜底)。把预期会发生的事当异常抛,等于承认你没为它设计路径。
但"给一个值"必须往前再走半步:给的这个值,语义对不对?
看一个反例——第 1 处 _parse_classify 的值一路流到了哪里(langgraph_rag_agent.py:2527 → :1174-1181):
# 解析端(langgraph_rag_agent.py:2527)
return data.get("type", "simple"), data.get("resolved", fallback_query)
# 路由端(langgraph_rag_agent.py:1174-1181)
qtype = state.get("query_type", "simple")
# v2 新意图收敛到图的三个合法键(comparison→complex,其余→chitchat;
# 专属处理留待 P3/P4)
if qtype == "complex" or qtype == "comparison":
return "complex"
if qtype in ("chitchat", "oos", "clarify", "feedback"):
return "chitchat"
return "simple"
连起来读,问题就出现了:
模型返回 {"type": "chichat"} (chitchat 拼错了)
↓
_parse_classify:data.get("type", "simple") → 返回 "chichat"
↓ (没有枚举校验,照单全收)
route_after_classify:
"chichat" != "complex"、"chichat" != "comparison"
"chichat" 不在 ("chitchat","oos","clarify","feedback") 里
↓
return "simple" ← 静默变成"走检索"
用户说"你好",系统去检索知识库,然后回答"未检索到相关内容"。
而这段代码的注释(:1175-1176)说明他们知道这类问题存在——comparison / oos / clarify / feedback 这些新意图名,被手工映射到图上的三个合法键。但映射写在路由端,不写在解析端。
本章的第四个反直觉判断: "兜底给了值"还不够,还要问一句"这个值有没有人校验过它合法"。
data.get("type", "simple")看起来非常安全——它连"字段缺失"都处理了。 但它只处理了存在性,没处理合法性:字段在、值是个字符串、这个字符串是不是那三个之一——没人查。 所以"兜住了但兜歪了"的完整形态是:字段级兜底做了,枚举级校验没做。
顺带看清同一件事在别处的表现——data.get(key, 默认值) 这个写法在本项目出现多次:
langgraph_rag_agent.py:2527 data.get("type", "simple") ← 存在性兜底
langgraph_rag_agent.py:2572 items = data.get(key, []) ← 存在性兜底(后有 isinstance + 非空校验)
advanced_rag_agent.py:1354 data.get("rewrites", [query]) ← 存在性兜底
advanced_rag_agent.py:1943 task=st.get("task", fallback_query) ← 存在性兜底
advanced_rag_agent.py:1944 skill_hint=st.get("skill_hint", "doc_search") ← 存在性兜底
这五处只有第 2 处(:2572)在后面跟了校验(if isinstance(items, list) and items:)。其余四处都是"给了默认值就结束"。
一条可以立刻用在自己项目上的判据:
get(key, default)处理的是"键在不在",不是"值对不对"。 前者是语法层面的兜底,后者才是语义层面的兜底。写完一个get(...)之后,多问一句"这个默认值能兜住'值非法'吗"——_extract_bool_json用isinstance(..., bool)回答了这个问题,_parse_classify没有。
第二部分小结 · 对照自查
- 我能按顺序说出四步阶梯,并说出每一步各防哪种脏输出
- 我知道 docstring 说的"三层兜底"和本章说的"四步阶梯"为什么不矛盾(兜底分支数 vs 动作数)
- 我能说出本项目有十处手写解析,以及两条把它们数出来的
grep(第二条专抓_extract_*) - 我能说出四种范式,并各举一处站点
- 我能解释查询改写的两套约定,以及"要求 JSON"为什么反而更脆
- 我能说出
_extract_scores的四层,以及第 3 层(逐行抓数字)的边界("85 分"会被读成 1.0) - 我能说出"字段级兜底 ≠ 枚举级校验",并能举出
data.get("type", "simple")这个反例
如果第 7 条说不顺,回到 §2.7 那个"拼错 chichat"的链路图——它是本章最值钱的一张图。
第三部分 · tool schema 怎么写
前两部分解决的是"别人给你的东西,怎么接住"。这一部分换方向:你手上的工具,怎么讲给模型听。
3.1 一行装饰器,schema 就有了
先看这个函数。它在 mcp_server.py:52-59:
@mcp.tool()
def calculator(expression: str) -> str:
"""
执行数学计算。适用于需要数值运算、单位换算等场景。
输入数学表达式(如 120/24 或 5*24),返回计算结果。
仅允许数字与 + - * / // % ** ( ) 运算符,杜绝任意代码执行。
"""
return _calc.execute(expression)
装饰器一行,函数定义三行,文档字符串三句。没有一行参数结构是手写的。
但凭什么是这样? 这里可以做一个不需要读文档的推理——排除法:
| 步骤 | 事实 | 来源 |
|---|---|---|
| ① | MCP 协议里一个 tool 必须带 name / description / inputSchema(即参数的 JSON Schema) | P0-05 §四「工具调用协议(Function Calling)」——p0_05_Agent规划与推理.md:86-93 逐字给出了这三件;本项目落点见 docs/guides/MCP_README.md:149(FastMCP 把 Skill 暴露为 Tools / Resource / Prompt) |
| ② | 本仓库零手写 JSON Schema | grep -rn "inputSchema" . --include=*.py → 零命中;grep -rn '"type": *"object"' . --include=*.py → 零命中(手写 schema 必留的痕迹,一处都没有) |
| ③ | 本仓库零 Pydantic(第四部分详证) | grep -rn "pydantic" . --include=*.py → 零命中;BaseModel / jsonschema 同为零 |
| ④ | 那 schema 从哪来? | 只能从函数签名派生 |
所以这一行装饰器在干的事,是把"你本来就写了的类型注解 + 文档字符串",翻译成协议要求的那三件东西:
@mcp.tool() + def calculator(expression: str) -> str: + docstring
│
├──→ name = "calculator" (函数名)
├──→ parameters = {expression: {type: "string"}} (类型注解)
└──→ description = docstring 那三句 (文档字符串)
关键判断:派生和手写,不是"省事"和"费事"的区别,是"单向"和"双向"的区别。下一小节会让你看到这个区别有多要命。
3.2 ★ 同一个工具,描述写了两遍 —— 而且漂移方向不一致
现在看一处很直观的问题。
计算器这个工具,它的描述写了两遍。
| 位置 | 内容 | 句数 | |
|---|---|---|---|
| 左 | skill_framework.py:176-177(CalculatorSkill.description) | 「执行数学计算。适用于需要数值运算、单位换算等场景。」+「输入数学表达式(如 120/24 或 5*24),返回计算结果。」 | 3 句 |
| 右 | mcp_server.py:55-57(docstring) | 同上两句 +「仅允许数字与 + - * / // % ** ( ) 运算符,杜绝任意代码执行。」 | 4 句 |
右边多出来的那一句,是关于安全边界的——它明确告诉模型:"这个工具只认表达式,你不要拿它干别的"。这是全篇描述里最能影响模型行为的一句。
而左边那份没有。
于是出现第一个"该信谁"的问题:模型是拿"它看到的那一份"来决定要不要用这个工具的。 而这两份,已经不一样了。
更要紧的是下一件事。 我本来以为这就是"抄漏了一句"——单向的、偶发的。于是我去看了第二个工具的描述:
| 位置 | 句数 | |
|---|---|---|
doc_search 的技能内描述 | advanced_rag_agent.py:1213-1215 | 3 句(含「适用于需要查找产品参数、协议说明、功能规格等文档内容时。」) |
doc_search 的 docstring | mcp_server.py:68-69 | 2 句(这句没有) |
方向反了。 计算器是"工具定义比技能定义多一句",文档检索是"工具定义比技能定义少一句"。
计算器 技能 3 句 ──→ 工具 4 句 (工具定义 多了算子白名单)
文档检索 技能 3 句 ──→ 工具 2 句 (工具定义 少了适用场景)
📌 这就是本集要讲的判据:只要同一个信息有两个地方能改,它就会漂——而且会朝两个方向漂。
如果只是"某次抄漏了一句",那它是一次事故,改回来就完了。但方向不一致说明它不是事故,是机制:这两份描述各自在演化,谁也没义务跟谁一致。
所以修法不是"下次仔细点"——是让这件事只可能有一个地方能写。
3.3 为什么"自动派生"是结构性的正解(而不是"写得仔细点")
把上一小节的关系画出来,两种做法的高下立刻就清楚了:
| 手写派 | 自动派生派(本项目) | |
|---|---|---|
| 参数结构的来源 | 两个(类型注解一个、手写 schema 一个) | 一个(类型注解) |
| 描述的来源 | 两个(技能类属性一个、docstring 一个) | 一个(docstring) |
| 箭头方向 | 双向、互不认账 | 单向 |
| 漂移 | 必然(不是偶然) | 不可能(没有第二个地方可写) |
| 要改描述时 | 得记得改两个地方 | 改一处 |
BaseSkill 里 name / description 是类属性,靠人维护——skill_framework.py:130-131:
name: str = "base_skill"
description: str = "基础技能"
这是手写派的位置。而 mcp_server.py 的 docstring 是派生派的来源。两套并存,就必然漂。
能直接带走的纪律:参数结构只能有一份来源。
不是"要写仔细一点"——是结构上就不该有第二个地方可写。
判据:同一个信息,如果有两个地方能改它,它就一定会漂。
3.4 schema 是一份"接口清单",所以它必须能被数出来
把这件事放到规模上看:grep -c "@mcp.tool()" mcp_server.py = 24。
24 个工具,就是 24 份 schema。 按业务域分:
| 域 | 个数 | 工具 |
|---|---|---|
| 通用能力 | 2 | calculator · doc_search |
| 角色管理 | 2 | list_biz_roles · create_biz_role |
| 主数据与库存 | 3 | list_products · query_inventory · query_stock_transactions |
| 销售 | 3 | create_sales_order · confirm_sales_order · query_sales_orders |
| 交付 | 3 | create_delivery · request_release_delivery · ship_delivery |
| 维修 | 7 | create_repair_order · classify_fault · assign_technician · start_repair · use_repair_parts · submit_resolution · query_repair_orders |
| 采购 | 3 | create_purchase_order · submit_purchase_order · list_pending_approvals |
| 预防性维护 | 1 | trigger_due_pm |
| 合计 | 24 | — |
如果这 24 份 schema 是手写的,它们会漂成 24 种样子。 因为手写意味着每个人按自己的理解写一遍,而 24 次书写不可能 24 次都一致。而它们全都从函数签名派生,所以只有一种样子。
这里有一个现成的、正在发生的证据。 我去查了"这个服务到底有几个工具",结果三个人给了三个答案:
| 来源 | 说法 | 对不对 |
|---|---|---|
| 代码 | grep -c "@mcp.tool()" = 24 | ✅ 权威口径(可复现) |
课程表_第06集起.md:96(v2 第 19 集那行) | 「24 个工具」 | ✅ 对 |
docs/guides/digital_employee_architecture.md:41 | 「19 个业务工具 + calculator + doc_search」 | ❌ 21 个,少 3 个 |
架构文档那一行还是手抄的。它写的时候可能是对的,后来加了 3 个工具,没人回来改它。
📌 所以"能被数出来"不是个洁癖要求。 一个连"有几个工具"都有人记错的地方,你没法指望它记住"某一句描述里有没有算子白名单"。
这也正是 Ch11 那句话在这一章的投影:能算回去的数字才是证据;算不回去的,只是形容词。
3.5 复杂参数为什么用 JSON 字符串传
再看一个第一眼很像"将就"的设计。mcp_server.py:118 的原文注释:
# 复杂参数(订单明细等)用 JSON 字符串传入,工具内解析校验。
订单明细这种结构,它不用嵌套对象传,用 JSON 字符串传。 项目里有 3 个工具收这种参数:
# mcp_server.py:258 / :428 / :508
def create_sales_order(customer_code: str, items_json: str, ...)
def use_repair_parts(order_id: int, items_json: str, ...)
def create_purchase_order(supplier_code: str, items_json: str, ...)
为什么不是 items: list[dict]? 因为这条链路的另一端不一定是 Python——MCP 的客户端可能是任何语言的实现,嵌套对象在各家客户端上的实现差别很大,而传输通道对复杂类型的表达能力也有限。
传一个字符串,谁都能传对。 至于字符串里合不合法,工具自己在门口验——mcp_server.py:150-155:
def _items(items_json: str):
import json
items = json.loads(items_json or "[]")
if not isinstance(items, list):
raise ValueError("items_json 须为 JSON 数组")
return items
注意 items_json or "[]":空值被当成空数组("没填"和"填了个空的"在这里是同一件事),而解析出来不是数组就拒绝。三行,把"类型"这一层收干净了。
代价也很明确:你得自己写解析和类型检查。 就这两行,不多——但它得有人记得写。第四部分会看到,"记得写"正是这个项目最大的结构性缺口。
3.6 错误也是一种输出
最后一件事最容易被忘:工具报错的时候,它吐出来的东西,也是模型要解析的一份输出。
项目里的做法是统一出口——mcp_server.py:158-160:
def _deny(e) -> str:
"""越权/业务错误统一出口(错误信息已含原因,审计已落 audit.log)。"""
return f"⛔ {e}"
两种做法的差别,就是"模型能不能自救":
| 抛异常派 | 统一出口派(本项目) | |
|---|---|---|
| 客户端收到 | 一个 500 | 一段带原因的文本 |
| 模型看到 | 一堵墙 | "⛔ 你为什么不行" |
| 模型下一步 | 只能瞎猜,或者放弃 | 能改参数、能换工具、能解释给用户 |
| 调用规模 | — | grep -c "_deny(" mcp_server.py = 23(含 1 处定义),即 22 处错误出口 |
📌 这一条把本章的两半缝上了:前面讲"怎么接住模型说的话",这一节讲"怎么让模型接住你说的话"。
它们的判据是同一条:这段文本有没有人能解析它? 工具的错误返回没有理由比模型的 JSON 输出更随意——它同样是一份结构化输出,只是方向相反。
⚠️ 一处诚实提示:
_deny的返回值在源码里带一个 emoji 前缀(mcp_server.py:160的f"⛔ {e}")。上屏物料里不能用这个字符——本项目用resvg渲染,emoji 会变成豆腐块,所以卡片上是手绘的红叉。代码里保留原样,物料里手绘替代,两者不冲突。
第三部分小结 · 对照自查
- 能说出"schema 从函数签名派生"这个结论,并且能用排除法(排除手写 schema、排除校验框架)自己推一遍,而不是背下来
- 知道计算器的描述写了两遍(
skill_framework.py:176-177三句 vsmcp_server.py:55-57四句),并能说出多出来的是哪一句 - 知道
doc_search的描述也写了两遍,但漂移方向相反(advanced_rag_agent.py:1213-1215三句 vsmcp_server.py:68-69两句) - 能说出漂移的根因不是"抄漏"而是"有两个地方能写",以及为什么"仔细点"治不了它
- 能报出工具数 24,并知道架构文档那一行已经错了(写 19 个业务工具 = 21 个)
- 知道复杂参数走 JSON 字符串(3 个工具),能说出为什么(跨语言客户端),以及代价(自己写解析 + 类型检查)
- 知道错误返回也是模型要解析的输出,以及统一出口
_deny(22 处)比抛异常好在哪
第四部分 · 校验器也该只有一份
前面两部分讲的是"输出进来怎么解析"。这一部分讲另一半:进来和出去的东西,到底谁来验。
4.1 四个手写校验器,一个总表
项目里有 4 个手写校验/归一化器:
| # | 名字 | 位置 | 管什么 | 层数 |
|---|---|---|---|---|
| 1 | validate_params | skill_framework.py:136-153 | 参数白名单:非空 / 长度 / 危险模式 | 3 |
| 2 | _items | mcp_server.py:150-155 | JSON 字符串参数的类型 | 1 |
| 3 | _to_score | evalkit/judge.py:151-156 | 分数落进合法区间 | 1 |
| 4 | normalize_code | agentworkflow/rules.py:180-184 | 根因编号的同义写法 | 1 |
四个都在干活,看起来挺齐全。但没有任何一处知道完整的规则。
这就像四个门卫各管一段路——中间那段没人管,而且你不知道是哪一段。 逐个看下来你会发现,它们每一个都写得很认真;问题从来不在质量,在于它们之间没有关系。
4.2 validate_params:三层,写得很扎实——但写在"技能里"
最厚的那一个,skill_framework.py:136-153:
def validate_params(self, query: str) -> Optional[str]:
"""
参数白名单校验。返回 None 表示通过,否则返回错误描述。
子类应覆写此方法以实现特定校验逻辑。
默认检查:非空 + 长度限制 + 危险模式。
"""
if not query or not query.strip():
return f"[{self.name}] 参数不能为空"
if len(query) > self.MAX_QUERY_LEN:
return f"[{self.name}] 参数过长(最大 {self.MAX_QUERY_LEN} 字符)"
lower = query.lower()
dangerous = ["__import__", "exec(", "eval(", "os.system", "subprocess",
"open(", "compile(", "globals(", "locals(", "getattr("]
for pattern in dangerous:
if pattern in lower:
return f"[{self.name}] 参数包含不被允许的字符模式"
return None
三层:非空 → 长度上限 → 危险模式黑名单(dangerous 列表实测 10 条)。写得很细,考虑得很全。
但请注意它写在哪里:写在技能类里面,而不是写在参数结构里。
再看长度的继承关系:
| 类 | MAX_QUERY_LEN | 位置 | 意图 |
|---|---|---|---|
BaseSkill | 2000(默认) | skill_framework.py:134 | 通用兜底 |
CalculatorSkill | 300 | skill_framework.py:180(# 数学表达式不宜过长) | 显式收窄 |
DocSearchSkill | 2000(继承,未覆写) | 无 | — |
前两行是有意的。第三行是没写——DocSearchSkill 既不覆写 validate_params,也不设 MAX_QUERY_LEN(advanced_rag_agent.py 全文无此变量),所以它原样继承了计算器那套规则。
这就引出了下一小节,本项目里我认为最值得讲的一处。
4.3 ★ 反直觉:继承来的校验器,误杀是必然的
validate_params 那 10 条黑名单,是为计算器写的——它的目的是"防任意代码执行",因为计算器的输入最终要交给求值器。
而 DocSearchSkill 的输入是自然语言查询——它只会被拿去检索向量库,根本不会被求值。但因为它继承了这个方法,黑名单照样生效。
我把真实代码跑了一遍(用的就是 mcp_server.py:78-81 那三行 DocSearchSkill.__new__(DocSearchSkill) 的构造方式,只建外壳、不触发重依赖):
$ python -c "
import advanced_rag_agent as A
probe = A.DocSearchSkill.__new__(A.DocSearchSkill)
for q in ['JM-S509 待机时间', 'open() 函数怎么用', 'subprocess 怎么调用', 'eval 的替代写法']:
print(repr(q), '->', probe.validate_params(q))
"
name= doc_search MAX_QUERY_LEN= 2000
'JM-S509 待机时间' -> None
'open() 函数怎么用' -> [doc_search] 参数包含不被允许的字符模式 ← 拦了
'subprocess 怎么调用' -> [doc_search] 参数包含不被允许的字符模式 ← 拦了
'eval 的替代写法' -> None ← 放了
三个结论,一个比一个值得注意:
| # | 观察 | 含义 |
|---|---|---|
| ① | MAX_QUERY_LEN = 2000 | 印证了继承(不是 300),一个检索查询允许 2000 字符 |
| ② | open() 函数怎么用、subprocess 怎么调用 被拦 | 误杀。 这个知识库讲的就是本项目自己的代码,里面本来就有 open( 和 subprocess。用户问它们是完全正常的 |
| ③ | eval 的替代写法 放行 | 漏放。 因为黑名单里存的是 "eval("(带括号),而这句话里 eval 后面没有括号 |
📌 这一处的价值,不在于"这里有个 bug",而在于它推翻了一个很自然的直觉。
我们通常认为:"复用同一份实现"总是好的——不管怎么说,它比复制粘贴强。
但这里的情况是:这份实现复用的是"代码",不是"语义"。 计算器的输入是要被执行的东西,检索的输入是要被理解的东西。把前者的安全规则套到后者身上,保护不了任何东西,只会挡住合法的提问。
所以真正的判据不是"有没有复用",而是——
这份校验,和它要保护的那个东西,是不是同一个语义?
而它之所以会发生,恰恰是因为校验器的位置:规则写在技能类里,子类默认就继承。"忘掉"继承和"忘掉"重写,成本完全不一样——前者是静默的,后者会被"没覆写抽象方法"报错拦住。
这也可以看成第三部分那个判据的另一面:同一份信息不该有两个地方能改;但同一个规则,也不该被无差别地套到两种东西上。
4.4 _to_score:一次 clamp 顶掉一类脏数据
第二个校验器,短得只有三行,但我觉得它是全篇性价比最高的一个。evalkit/judge.py:151-156:
def _to_score(v: Any) -> Optional[float]:
try:
f = float(v)
return max(0.0, min(1.0, f))
except (TypeError, ValueError):
return None
它做的事只有一件:把模型给的分数,夹进 0 到 1 之间。
模型给 1.5 ──┐
模型给 -0.3 ──┼──→ max(0.0, min(1.0, f)) ──→ 合法的 0~1
模型给 "0.85" ─┘
模型给 "高" ──→ float() 抛 ValueError ──→ None(明确的缺失)
关键在于它做的是什么、不做什么。 它不判断你对不对——它只保证你落在合法范围内。这叫归一化。
而在第二部分我们已经见过它的上游了:_extract_scores 会把模型写的**「85 分」解析成 0.85**。如果没有那一次换算,85 会被 clamp 成 1.0——一个看起来很正常的满分。归一化和换算是两个动作,缺一个就出事。
_to_score的注释价值:它示范了"一次转换,顶掉一整类脏数据"。脏数据到这里就断了,往下走的每一层都不必再操心"分数会不会越界"。三行代码,消灭一个问题类别。这比写十行防御性判断划算得多。
4.5 _items:它故意 raise,因为上游有人接
回看第三部分那个 _items(mcp_server.py:150-155),它和 _to_score 的处理方式看起来相反:一个 raise ValueError,一个 return None。
这不是不一致,是"谁来接"不同。
_items 的每个调用点,都长在这样一个壳里——mcp_server.py:266-273:
try:
erp = _layer()
o = erp["sales"].create_order(
biz_role, acting_user, tenant, customer_code, _items(items_json))
return (f"✓ 销售订单已创建 {o['order_no']}…")
except Exception as e:
return _deny(e)
所以 _items 抛出的 ValueError,往上走一层就被工具的 except Exception 接住,转成 _deny 的结构化错误文本,回到模型手里。raise 是故意的。
而 _to_score 在判分链路上,没有一个"工具边界"来接异常——那里 None 就是"这项没分"的合法表达。所以它不该抛。
_items | _to_score | |
|---|---|---|
| 失败动作 | raise ValueError | return None |
| 谁来接 | 工具体的 except Exception → _deny | 上层把 None 当"缺失" |
| 判据 | 失败必须变成一个给模型看的文本 | 失败必须变成一个可判定的缺失 |
⚠️ 但"统一出口"有它的另一面,这里必须说清。
except Exception as e: return _deny(e)抓的是所有异常。这意味着:"业务上不允许"(该让模型改参数)和"代码写错了"(该让人去看日志)——在模型眼里是同一种输出。前者模型能自救,后者模型只会拿着一个看起来像业务拒绝的东西去换个参数再试一次,而真正的缺陷一直没人知道。
_deny的 docstring 写的是"越权/业务错误统一出口(错误信息已含原因,审计已落 audit.log)"——"业务错误"这四个字,在宽except之下是没有边界保证的。统一出口让"错误可解析"这个目标达成了,但它同时抹掉了错误的分类。 这不是要改掉它——统一出口是对的;要补的是分类:哪些异常算业务拒绝(走
⛔),哪些算自身缺陷(该走日志/告警)。
4.6 normalize_code:先归一化,再判断;越界是拒绝,不是猜
第四个校验器,它解决的是同义写法。agentworkflow/rules.py:180-184:
def normalize_code(v: Any) -> Optional[str]:
""""R5" / "r5" / "5" → "R5";非法值返回 None。"""
s = str(v or "").strip().upper()
m = re.match(r"^R?([1-8])$", s)
return f"R{m.group(1)}" if m else None
四行,行为可以列成一张表:
| 输入 | 输出 | 说明 |
|---|---|---|
"R5" | R5 | 标准写法 |
"r5" | R5 | 大小写归一(.upper()) |
"5" | R5 | 补前缀(^R?) |
" R5 " | R5 | 去空白(.strip()) |
"R9" | None | 越界 → 拒绝 |
最后一行是关键。 如果模型给一个超出范围的编号(规则只有 R1–R8),它返回的是空值——拒绝,而不是猜一个最接近的。
而返回 None 之后会发生什么,源码写在 parse_conclusion 的 docstring 里(agentworkflow/rules.py:188-193):
"""
解析 ReAct 探查的 Final Answer。
期望 JSON:{"code":"R1"|"R5"|...|null, "evidence":"...", "suggestion":"..."}
解析失败 → code=None(上层据此「探查完成但未归因,转人工」,不硬猜)。
"""
"不硬猜"这三个字,是这一小节的判据。
归一化器是校验器的姊妹:它负责"接受合理的变体",但绝不负责"替模型圆谎"。
差一点点就顺手填一个"最接近的"——那才是真正危险的开始。因为那样一来,系统会给出一个看起来很确定的错误结论,而下游没有任何人能发现它错了。
"转人工"是一个正确的结果。 它比一个编出来的答案便宜得多。
4.7 ★ 诚实缺口:没有那套流行框架
现在说一件可能会让这个项目显得不那么专业的事,但我认为必须正面讲。
看到这里你可能早就在想:这些校验,为什么不用那套流行的数据校验框架?用一个模型类声明字段和类型,不是更省事吗?
我去查了:
$ grep -rn "pydantic" . --include=*.py | grep -v '^./.git' | wc -l
0
$ grep -rn "BaseModel\|jsonschema" . --include=*.py | grep -v '^./.git' | wc -l
0
零命中。 整个项目里没有任何一处用了它,校验全靠刚刚那四个手写器。
这是一个选择,而且是有代价的选择:
| 换来的 | 付出的 | |
|---|---|---|
| 依赖 | 零依赖(不用装校验框架) | 校验规则散在四个文件里 |
| 启动 | 启动快、少一层解析 | 类型检查靠人记得写(_items 那两行没人提醒你写) |
| 报错 | — | 没有统一的错误结构,靠返回字符串 |
| 可读性 | 代码就是 Python,没有框架魔法 | 新人得读四个地方才知道规则全貌 |
我要把它和课程表的问题一起说清,因为这里正好有一次口径打架。 课程表里关于这一集有两句话:
| 位置 | 原文 |
|---|---|
课程表_第06集起.md:54(插这一集的理由) | 「全套无 JSON Schema 校验;原 Ch15 直接上 MCP 协议,缺工具 schema 基础」 |
课程表_第06集起.md:84(这一集的内容) | 「JSON Schema 约束、Pydantic 校验、解析失败重试、tool schema 怎么写、为 MCP 铺垫」 |
两句话说的是两回事,而严格按代码核,两句都不准确:
| 课程表的说法 | 代码事实 | 判定 |
|---|---|---|
| 「缺工具 schema 基础」 | 工具 schema 有,24 份,从函数签名派生 | ❌ 不准确——缺的不是 schema,是 schema 的独立校验 |
| 「JSON Schema 约束」 | 零手写 schema、零校验器 | ⚠️ 只有"派生",没有"约束" |
| 「Pydantic 校验」 | 零命中 | ❌ 不存在 |
| 「解析失败重试」 | json.JSONDecodeError 共 9 处,其中 7 处在解析模型输出,全部兜底降级、0 处重试(不重新调用模型) | ❌ 不存在 |
🔴 这一集里有两条"不许说":不许说"本项目用 Pydantic 校验"、不许说"解析失败会重试"。两者在代码里都不存在。
还有一个名字会骗人的坑:
langgraph_rag_agent.py:1860有一份提示词叫rewrite_retry——它不是"解析失败重试",它是第二轮检索用的改写提示词(源码里按"上一轮有没有文档"分两支:if prev_docs is None: … else: …)。看到名字里有 retry 就以为有重试,是本集最容易踩的一个阅读陷阱。
那正确的姿态是什么? 不是包装成"我们有一套等价方案",而是:
没用某个流行工具,不等于你做得不对。 判断标准应该是两条:它解决的问题,你有没有?它的代价,你愿不愿意付?
这个项目的答案是:问题(脏数据)确实有,而且很认真地处理了(就是前面那四个校验器和十处兜底);代价(分散、靠人记得)也确实是它现在最薄弱的地方——这正是下一部分要写进契约的东西。
第四部分小结 · 对照自查
- 能报出 4 个校验/归一化器及各自位置(
skill_framework.py:136/mcp_server.py:150/evalkit/judge.py:151/agentworkflow/rules.py:180) - 记得
validate_params是三层(非空 / 长度 / 10 条危险模式),且写在技能类里 - 能说出
DocSearchSkill继承了计算器那套黑名单(MAX_QUERY_LEN实测 2000,未覆写),并能举出误杀实例(open() 函数怎么用、subprocess 怎么调用被拦)与漏放实例(eval 的替代写法放行,因为黑名单存的是"eval(") - 能说出这一处的真判据:复用的必须是语义,不只是代码——检索的输入不会被求值,黑名单保护不了任何东西
- 知道
_to_score是归一化不是判断对错,并知道它必须配上上游的「85 分 → 0.85」换算,否则 85 会被 clamp 成 1.0 - 知道
_items故意raise(上游工具体的except Exception接住 →_deny),而_to_score返回None,两者不是不一致 - 能说出"统一出口"的另一面:宽
except让"业务拒绝"和"程序缺陷"在模型眼里长得一样 - 知道
normalize_code的越界返回None对应"不硬猜,转人工",并能说出为什么"拒绝"比"编一个"便宜 - 🔴 能明确说出本项目没有 Pydantic、没有 jsonschema、解析失败不重试,并知道课程表里那两句(
:54/:84)都不准 - 能识破
rewrite_retry这个名字——它是第二轮检索的改写提示词,不是解析重试
第五部分 · 把契约写下来
前四部分把问题摆完了:十处手写解析、四处分散校验、两份漂移描述、一批静默兜底。
这一部分只回答一个问题:那这份契约具体写在哪儿?
答案是三个地方。
5.1 契约的三个落点
| # | 落点 | 要写成什么 | 出处 |
|---|---|---|---|
| ① | 参数结构 | 只留一处来源——让它从类型注解 + docstring 自动派生,别手抄 | 第三部分的双塔漂移 |
| ② | 解析兜底 | 只留一处——把那个"四步阶梯"抽成一个共用函数,谁要解析模型输出都来调它 | 第二部分的十处手写 |
| ③ | 缺口 | 也要写下来——"本项目没有用那套校验框架"这件事得写进文档,别让下一个人从代码里猜 | 第四部分 §4.7 |
第三条最容易被跳过,但它其实最要紧。因为猜出来的结论,通常是"这块没人管"。
三条合起来就是一句话:写不下来的约定,等于没有约定。
注意这三点有个共同结构:它们都在把"多个地方"收敛成"一个地方"。 而"多个地方"之所以会出现,从来不是因为谁偷懒——是因为每次单点改动都很快,而"要不要抽出来"这个决定很慢。 所以它一定会累积到你某一次改漏了才被发现。
5.2 改进项:现在 vs 目标(★尚未实现)
如果把这份契约真正落地,前后差别是这样的:
| 现在(代码事实) | 目标(改进项) | |
|---|---|---|
| 解析模型输出 | 10 处手写,层数 0–4 不等 | 1 个共用解析阶梯函数 |
| 参数/数据校验 | 4 个手写器,散在 4 个文件 | 1 套统一校验 |
| 工具描述 | 2 份(技能类属性 + docstring),已双向漂移 | 1 份来源(docstring),自动派生 |
| 解析失败的可见性 | 静默(见 §5.4) | 每次降级有分类、有记录 |
🔴 必须说清楚:右边这一栏是改进项,还没有实现。
它不是这个项目的现状,它是这一集给你的一张图纸。 我之所以把它画出来,是因为这个差距本身就是内容:
"我现在有十处"和"我该只有一处",中间隔着的是一次重构。而重构的动因,通常不是"代码不好看",是"下一次改的时候改漏了"。
这跟 Ch10 处理"权限表达式四份手抄"是同一个做法——改进项必须明标"尚未实现",否则会被当成现状,而下一个人会照着"目标"去理解代码,然后发现自己被骗了。
5.3 体检清单:五个今晚就能答完的问题
这一集最实用的东西,可能不是任何一个技术点,而是这五个问题:
| # | 问题 | 怎么答 |
|---|---|---|
| 1 | 你的解析函数有几个? | grep -rn "def _parse|def parse_|def _extract_" . --include=*.py |
| 2 | 它们的兜底层数一样吗? | 逐个看 except 分支——不一样就是本章的问题 |
| 3 | 你的工具描述是不是只有一份? | 同一个工具,在几个文件里能找到它的描述? |
| 4 | 复杂参数怎么传的、校验了吗? | 找 _json 结尾的参数,看有没有 isinstance |
| 5 | 你上次改参数结构,改了几个地方? | 回想一下——如果要改 3 个地方,那就有 3 个地方会漂 |
五分钟就能答完。 答完你就知道,自己项目里有没有这一集九成的问题。
这也是这一集为什么不给你"下一步该学什么"——它只让你去看一眼自己的代码。
5.4 ★ 诚实缺口清单(不止"没有 Pydantic"一条)
第四部分讲了"没有 Pydantic"。但把整章的核查结果摊开,缺口其实有五条,而且后几条比第一条更值得知道:
| # | 缺口 | 证据 | 影响 |
|---|---|---|---|
| 1 | 没有任何 schema 校验框架 | pydantic / BaseModel / jsonschema 全仓零命中 | 规则散在四处,类型检查靠人记得 |
| 2 | 解析兜底是完全静默的 | 本章涉及的 8 个源文件里,logging. 命中数全部为 0;而 4 个主要解析函数(_parse_classify / _parse_json_list / _parse_react_output / _parse_llm_intent)的兜底分支里连 print 都没有 | 降级了,没人知道 |
| 3 | 校验规则的位置在"技能类"里 | validate_params 写在 BaseSkill 内,子类默认继承 | 语义不匹配的误杀(§4.3 实测) |
| 4 | 统一出口抹掉了错误分类 | except Exception as e: return _deny(e)(22 处) | 业务拒绝与程序缺陷在模型眼里一样 |
| 5 | 文档口径会漂 | 架构文档写"19 个业务工具"(实为 22 个,共 24 个工具);课程表 :54 与 :84 自相矛盾 | 下一个人读到的是错的 |
第 2 条我要单独说重一点。
它和 Ch11 的那个教训是同一件事:Ch11 的评测日志里,出现过同一轮里 5 次 rerank 回退(502)仍然照报 66.7% 的记录——降级进入了度量本身,而报告上什么都看不出来。 那一集的结论是"一次静默降级就让两组数字不可比"。
而本章的情况更基础:解析这一层的降级,是彻底不出声的。
模型返回了脏 JSON
→ 正则兜底抓到了花括号 ✅ 走下去了,但是哪一层接住的?没人记
→ 正则也没抓到,规则分类兜底 ✅ 走下去了,但是"模型答错了"还是"解析没跟上"?没人记
⚠️ 这不是"要不要打日志"的洁癖。 是:当这个系统的行为变差时,你没有任何一条线索指向"解析层"——因为它从头到尾没说过一句话。
而它跟前面每一章的降级都不一样:ch06–ch11 的降级,多数会体现在数字上(召回率、泄漏数、pass 数)。解析层的降级只会体现为一个"看起来正常的答案"。
5.5 一个反直觉的判据
最后给一个判据,它有点反直觉。
如果你的约定,只存在于"我和同事的默契"里——那它已经过期了。
它可能今天是对的,明天也对,看上去完全没问题。但它没有一个能被改的地方——你想改,都不知道该上哪儿改。
而一个不能被改的约定,就不是约定。
这个结论在本系列里出现过一次同型版本——Ch10 的"四份手抄的权限规则,等于没有权限规则"。
| Ch10(权限) | Ch12(本章) | |
|---|---|---|
| 分散的东西 | 权限表达式,四份手抄 | 解析阶梯,十处手写 |
| 为什么危险 | 四份会不一致,且不一致时不报错 | 十处会不一致,且不一致时不报错 |
| 共同结论 | 分散的约定不算约定 | 分散的约定不算约定 |
凡是约定,就必须有一个能被改的地方。
能写下来的才是约定,写不下来的是运气。
第五部分小结 · 对照自查
- 能报出契约的三个落点(schema 一处来源 / 兜底一处 / 缺口一处),并说出为什么第三条最容易被跳过
- 能说清现在 vs 目标的四行差距,并记住右边是"改进项 · 尚未实现",不能当现状讲
- 五个体检问题能不查资料就复述
- 能报出五条缺口,而不只是一条"没有 Pydantic"
- 能说出第 2 条缺口的证据(8 个文件
logging.零命中;4 个主要解析函数的兜底分支连print都没有),并能联系到 Ch11 那次"降级进入度量" - 能说出本章的元命题:凡是约定,就必须有一个能被改的地方,并知道它和 Ch10"四份手抄"是同型结论
踩坑总表
把这一章的坑摆到一张表上——九个。其中五个是上屏级别,全部 fatal:
| # | 坑 | 现象 | 根因 | 严重度 |
|---|---|---|---|---|
| 1 | 把"它会返回 JSON"当契约 | 提示词里写了"请以 JSON 格式输出",代码里就只有一句 json.loads | 把软约束("引导")当成了硬保证——P0-02 §五 明说 JSON Mode 只是"引导" | 高危(脏输出直接打穿全链) |
| 2 | 有兜底,但缺了正则那一层 | 剥完围栏就 loads,遇到「{...} 后面还聊一句」立刻失败 | 四种脏输出里,「不完整 JSON」和「夹带文字」才是常态,而 loads 只认前两种 | 高危(兜了个寂寞) |
| 3 | 同一件事十处手写,层数还不一样 | 有的 4 层、有的 2 层、有的只有 1 层、有的一层都没有 | 每次都是"就地加个 try"——单点改动快,抽象很慢;于是必然累积 | 高危(必然漂移) |
| 4 | 同一份工具描述写了两遍,且双向漂移 | 计算器:技能 3 句 vs 工具 4 句;文档检索:技能 3 句 vs 工具 2 句 | 描述有两个来源(类属性 + docstring),而模型只看它拿到的那一份 | 高危(模型行为随来源变) |
| 5 | 用默认值把"字段缺失"静默吞掉 | data.get("type", "simple") —— 模型把 chitchat 拼成 chichat,系统照常走检索 | 字段级兜底 ≠ 枚举级校验;route_after_classify 落到最后一行 return "simple" | 高危(最阴:结果看起来完全正常) |
| 6 | 校验器写在技能类里,子类默认继承 | DocSearchSkill 继承了"防代码执行"的黑名单,open() 函数怎么用 被拒 | 继承来的是代码,不是语义——检索输入根本不会被求值 | 中等(既误杀又漏放) |
| 7 | 统一出口的宽 except 抹掉错误分类 | except Exception as e: return _deny(e)(22 处) | 业务拒绝和程序缺陷,在模型眼里长得一样 | 中等(缺陷被当成业务拒绝,一直在重试) |
| 8 | 解析兜底是静默的 | 8 个源文件 logging. 零命中;4 个主要解析函数的兜底分支连 print 都没有 | 降级不给任何线索 | 中等(系统变差了,无从归因) |
| 9 | 文档口径与代码不一致 | 架构文档写"19 个业务工具"(实为 22 个);课程表 :54 与 :84 自相矛盾 | 手抄的清单不会自己更新 | 次要(误导下一个人) |
第 1~5 条是一组,它们是本章的定性:全都是 fatal,而且没有一条会报错。
这五条和前几集的坑性质不同:
集 坑的成因 Ch10(权限) 想不到——"维度漏掉"比"边界写错"危险 Ch11(评测) 想省事——自建一套检索省事、拿 chunk_index当标识省事Ch12(本章) 想当然——"模型大概会这样"当成了"模型一定会这样" 所以本章的药方不是"更小心",是"别再假设对方会配合你"。
因为"它会返回 JSON"这句话,本身就是一次没有写下来的假设——而没写下来的假设,不会有人去检查它。
对应解法速查:
# 坑 1:先承认"软约束不是保证",再决定兜底写到哪一层
# P0-02 §五「结构化输出约束」
# JSON Mode = 软(引导,仍可能格式漂移)
# Function Calling = 软
# grammar 约束解码 = 硬(数学上保证可解析)← 本地链路拿不到
# 判据:你要的是"它通常会",还是"它必须"?后者只能靠后置校验
# 坑 2:四步阶梯——剥围栏 → 直接 loads → 正则抓 {...} → 兜底给一个值
# langgraph_rag_agent.py:2521-2539
result = re.sub(r"```(?:json)?\s*", "", result) # 前置:剥围栏
try:
data = json.loads(result) # 第 1 层
except (json.JSONDecodeError, TypeError):
match = re.search(r'\{.*\}', result, re.DOTALL) # 第 2 层:正则抓花括号
if match:
try:
data = json.loads(match.group())
except (json.JSONDecodeError, TypeError):
pass
return self._quick_classify(fallback_query), fallback_query # 第 3 层:给值
# 判据:少了第 2 层,"JSON 后面还聊一句"就直接掉到第 3 层——而你以为是模型答错了
# 坑 3:解析阶梯只该有一份
# 现状(十处,层数 0-4)→ 目标(一个共用函数)
# 自查:grep -rn "def _parse\|def parse_\|def _extract_" . --include=*.py
# 判据:下一处要解析模型输出时,你会"复制哪个函数"?——复制哪个都是错的
# 坑 4:参数结构与描述,都只留一处来源
# ✅ mcp_server.py:52-59 —— 类型注解 + docstring,自动派生
# ❌ skill_framework.py:176-177 —— 类属性 description,手写第二份
# 自查:同一个工具的描述,能在几个文件里找到?
# 判据:同一份信息有两个地方能改,它就一定会漂——而且会朝两个方向漂
# 坑 5:字段级给默认值,不等于"这个值合法"
# 解析端(langgraph_rag_agent.py:2527)
return data.get("type", "simple"), data.get("resolved", fallback_query)
# ↑ 缺字段给默认值——但拼错的 "chichat" 也当成缺字段
# 路由端(langgraph_rag_agent.py:1174-1181)
qtype = state.get("query_type", "simple")
if qtype == "complex" or qtype == "comparison":
return "complex"
if qtype in ("chitchat", "oos", "clarify", "feedback"):
return "chitchat"
return "simple" # ← 拼错的 "chichat" 落在这里,静默走检索
# 自救动作:给枚举加一次白名单校验——不在集合里就退回默认,并让这件事可见
# 坑 6:复用的必须是语义,不只是代码
# ❌ DocSearchSkill 继承 validate_params(为"要被求值的输入"写的 10 条黑名单)
# 实测:'open() 函数怎么用' → 拦;'subprocess 怎么调用' → 拦
# 'eval 的替代写法' → 放(黑名单存的是 "eval(",带括号)
# 判据:这份校验,和它要保护的那个东西,是不是同一个语义?
# 坑 7:统一出口要补"分类",不能只有"统一"
# mcp_server.py:272 / 各工具统一壳
try:
...
except Exception as e:
return _deny(e) # 22 处——业务拒绝与程序缺陷混成同一种输出
# 判据:模型收到这条错误,它该"改参数",还是"该有人去看日志"?
# 坑 8:降级必须留痕,而且要被归类
# 自查:grep -c 'logging\.' <每个解析相关文件> → 本项目 8 个文件全部为 0
# 反例代价:Ch11 同一轮里 5 次 rerank 回退(502)仍照报 66.7% —— 降级进了度量
# 判据:系统变差时,有没有一条线索指向"解析层"?
# 坑 9:清单要能"算出来",不能"抄下来"
grep -c "@mcp.tool()" mcp_server.py # 24(权威口径,可复现)
# 架构文档写"19 个业务工具 + calculator + doc_search" = 21 —— 少 3 个
# 课程表_第06集起.md:54「缺工具 schema 基础」/ :84「Pydantic 校验」—— 两句都不准
# 判据:这个数字,别人照你的命令能跑出同一个吗?
学习目标回顾
对着开头的四个目标,逐个 check:
- ✓ 目标 1 · 说清"模型会返回 JSON"为什么是错觉——因为提示词对模型是软约束,不是保证。P0-02 §五 把保证强度分成三档:JSON Mode 软 / Function Calling 软 / grammar 约束解码硬;本项目走的是本地链路(数据不出内网),拿不到硬保证,所以"模型通常会返回 JSON"和"模型必须返回 JSON"之间,隔着整整一层后置校验。四种脏输出你已经能背出来了,你拿下了
- ✓ 目标 2 · 会写"三层兜底阶梯"——剥围栏 → 直接
loads→ 正则抓{...}→ 兜底给一个值。最后一层必须给值而不是抛异常,因为在这一层"解析失败"是一次降级,不是一次故障;而"重试一次"不是答案,因为同一个模型、同一个提示词、同一次采样,没有理由给你不同的结果,你拿下了 - ✓ 目标 3 · 能说清 tool schema 的两条来源——
@mcp.tool()的 schema 从类型注解 + docstring 自动派生(单向),BaseSkill的description是手写类属性(第二处来源)。两者并存 → 同一个工具的同一句描述写了两遍,而且我核出漂移方向是相反的(计算器工具定义多一句、文档检索工具定义少一句)。复杂参数走 JSON 字符串,是因为链路另一端不一定是 Python,你拿下了 - ✓ 目标 4 · 能判断"校验器该有几份"——这一条我给你补了一个原目标里没有的判据:不是"几个"的问题,是"语义是否相同"的问题。本项目有 4 个手写校验器,它们管的是 4 件不同的事,这没问题;真正的问题在 §4.3——
DocSearchSkill继承了为计算器写的黑名单,而检索的输入根本不会被求值,于是既误杀(open() 函数怎么用)又漏放(eval 的替代写法)。零 Pydantic / 零 jsonschema 这个事实、以及它换来了什么代价,你也拿下了
四个全过。如果只能带走一句,带这句:
凡是约定,就必须有一个能被改的地方。
再加一句本章特有的:
最危险的降级,是那个"兜住了但兜歪了"的降级——它不报错,只是悄悄给了一个看起来正常的答案。
知识点卡片
【知识点】三档保证:软约束 vs 硬保证
定义:让模型输出结构化数据的手段,按保证强度分三档——JSON Mode = 软(引导,仍可能格式漂移);Function Calling = 软;grammar-constrained decoding = 硬(数学上保证可解析)。
手段 保证强度 谁在保证 提示词里写"请以 JSON 格式输出" 软 模型的"配合意愿" JSON Mode / Function Calling 软 采样时的引导 grammar 约束解码 硬 解码器(数学保证) 后置校验 + 兜底 兜得住 你的代码 依据:P0-02 §五「结构化输出约束」,原句是"抽字段、调 API 这类要确定性解析的任务,优先 grammar decoding 或后置校验,别只信 JSON Mode"。
本项目形态:走的是本地链路(数据不出内网 → 只能自建本地模型服务 → 这条链路拿不到 grammar 约束解码),所以本项目只有"软 + 后置校验"这条路——十处手写解析就是那个"后置校验"。
判据:你要的是"它通常会",还是"它必须"? 要"必须",就只能在你这一侧建立保证。
跨领域同构:HTTP 的
Content-Type声明、数据库的NOT NULL、接口的"我们约定字段不为空"——声明从来不是保证,校验才是。
【知识点】四步兜底阶梯:把"解析失败"当成降级,而不是故障
定义:解析模型文本输出的标准四步——① 剥围栏(前置)→ ② 直接
loads→ ③ 正则抓{...}再loads→ ④ 兜底给一个值。
步骤 处理哪类脏输出 ① 剥围栏( re.sub(r"```(?:json)?\s*", "", result))带代码块标记的 ② 直接 loads纯 JSON ③ 正则抓 \{.*\}(re.DOTALL)再loadsJSON 后面还聊了一句 / 前面带前言 / 花括号外还有字 ④ 兜底给值 完全非 JSON 关键判断:最后一层必须给值,不能抛异常。 因为在这一层,"解析失败"是一次降级,不是一次故障——抛异常会让整条链路停在一个本可以继续的地方。
但要注意两件事:① ①是"归一化"不是"兜底"(它在
try之外,是无条件的前置清理),所以 docstring 说"三层"、数动作是"四步",两者都对;② 降级必须留痕,否则就是本章坑 8。判据:剥离装饰、直接解析、正则抢救、给值降级——少了哪一步,都有一整类脏输出会直接掉到最后一层。
【知识点】字段级兜底 ≠ 枚举级校验(默认值的静默陷阱)
定义:
d.get(key, default)只解决"这个键不存在",完全不解决"这个值是否合法"。当合法值是一个有限集合时,必须额外做一次白名单校验。# 解析端:缺字段 → "simple"(合理) return data.get("type", "simple"), data.get("resolved", fallback_query) # ↑ 但模型拼错的 "chichat" 也会被当成"缺字段" # 路由端:只认 4 个合法键,其余全部落到最后一行 if qtype in ("chitchat", "oos", "clarify", "feedback"): return "chitchat" return "simple" # ← "chichat" 静默走检索本项目形态:
langgraph_rag_agent.py:2527 / :2534(解析端)+:1174-1181(路由端)。一个拼错的枚举值,不会报错、不会告警,只会走错分支。判据:给了默认值之后,再问一句——"这个值,有没有人校验过它合法?"
跨领域同构:配置文件的枚举项、状态机的状态值、埋点的
event_type——任何"有限取值"的字段,都给默认值是不够的。
【知识点】单一来源原则:派生 vs 手写
定义:同一份信息只能有一个可以修改的地方。多个来源 = 必然漂移(而且是双向漂移)。
手写(两个来源) 派生(一个来源) 参数结构 类型注解 + 手写 schema 类型注解 工具描述 类属性 + docstring docstring 箭头 双向、互不认账 单向 本项目形态:
mcp_server.py:52-59是派生派(@mcp.tool()+ 类型注解 + docstring);skill_framework.py:176-177是手写派(类属性description)。两套并存,于是同一个计算器的描述写了两遍,且与doc_search的漂移方向相反。判据:同一个信息,如果有两个地方能改它,它就一定会漂。
跨领域同构:API 文档与代码注释、README 里的版本号与
package.json、DB 表结构与迁移脚本——凡是"要记得同步"的地方,都是已经不同步的地方。
【知识点】归一化不是判断对错;越界是拒绝,不是猜
定义:归一化只保证值落在合法范围/标准形式内,它不评价内容对不对。两种典型形态——钳位(clamp) 与 同义收敛(normalize)。
形态 例子 行为 钳位 _to_score:max(0.0, min(1.0, f))1.5 → 1.0、-0.3 → 0.0、"高" → None同义收敛 normalize_code:^R?([1-8])$"r5" / "5" / " R5 " → R5越界 上面那个的 "R9"None(拒绝)——不猜"最接近的"本项目形态:
evalkit/judge.py:151-156、agentworkflow/rules.py:180-184。归一化器的姊妹是上游的换算——_extract_scores先把模型写的「85 分」换算成0.85,否则85会被 clamp 成满分1.0。换算教它"是什么",归一化保证"落在哪"。判据:归一化器接受合理的变体,但绝不替模型圆谎。 "拒绝"是正确的结果——它比一个编出来的答案便宜得多。
跨领域同构:输入框里的手机号格式化、日期解析、金额精度——格式可以宽容,语义不能猜。
【知识点】复用的必须是语义,不只是代码
定义:"复用同一份实现"不等于正确。当两个调用点的输入语义不同时,把 A 的规则套到 B 上,会同时产生误杀和漏放。
计算器( CalculatorSkill)检索( DocSearchSkill)输入的命运 要被求值 只会被拿去检索 黑名单有无意义 有(防任意代码执行) 无 实测结果 — open() 函数怎么用被拦;eval 的替代写法放行本项目形态:
DocSearchSkill(advanced_rag_agent.py:1187)既不覆写validate_params,也不设MAX_QUERY_LEN(实测继承到 2000,不是计算器的 300),于是原样继承了为计算器写的 10 条黑名单。而漏放和误杀的原因同一个:子串匹配没有语义。判据:这份校验,和它要保护的那个东西,是不是同一个语义?
跨领域同构:给"用户昵称"套上"SQL 注入过滤"、给"自然语言提问"套上"命令注入黑名单"——防护措施和它要防的攻击,必须作用在同一个入口上。
【知识点】约定必须有一个能被改的地方
定义:一个约定如果只存在于人的默契里,它就没有一个可以被修改的位置——不能被改的约定,不是约定。
集 分散的东西 危险在哪 Ch10 权限表达式,四份手抄 四份会不一致,且不一致时不报错 Ch12 解析阶梯,十处手写;工具描述,两份 会不一致,且不一致时不报错 本项目形态:十处手写解析(层数 0–4)、四处分散校验、两份漂移描述。每一项单看都写得很认真——问题从来不在质量,在于它们之间没有关系。
判据:这个约定写在哪儿?如果没人知道该去哪儿改它,那它就已经过期了。
跨领域同构:团队口头规范、"我们一般这么写"、只有老员工知道的操作顺序——凡是靠"记得"维持的东西,都会在换人时失效。
练习题
基础题
1. 四步兜底阶梯里,第 ③ 步「正则抓花括号再 loads」看起来是多余的——直接 loads 失败后兜底给个值不就行了吗?请说明为什么不能去掉它,并给出一段"只有第 ③ 步能救"的模型输出。
答案
因为"完全非 JSON"和"JSON 外面还包了一层"是两类完全不同的故障,而它们的修复方向相反。
失败后直接兜底(❌ 只有两步):
纯 JSON → ② 成功 ─┐
带代码块 → ① 剥掉 → ② 成功
JSON 后面还聊一句 → ② 失败 ─┐
完全非 JSON → ② 失败 ─┴─→ ④ 兜底给值
↑ 两类都掉到这里,**分不出来**
有第 ③ 步(✅ 四步):
JSON 后面还聊一句 → ② 失败 → ③ 正则抓到 {...} → 成功
完全非 JSON → ② 失败 → ③ 也抓不到 → ④ 兜底
↑ 只有真·非 JSON 才降级
关键差别:少了第 ③ 步,那些"JSON 其实是对的,只是前后多了字"的输出会被当成模型答错了处理——你降级降级了一堆本来完全可用的结果,而且看不出是为什么。
一段只有第 ③ 步能救的输出(项目的 docstring 里就列了这一类):
好的,我来分析这个问题。
{"type": "complex", "resolved": "对比 JM-S509 和 JM-T700 的待机时间"}
如果需要我进一步拆解子任务,请告诉我。
②直接 loads 必然 JSONDecodeError(前面有"好的,我来分析这个问题。",后面还有一句);①也剥不掉(它没有围栏)。只有 re.search(r'\{.*\}', result, re.DOTALL) 能把它捞出来。 注意 re.DOTALL 是必需的——没有它,. 不匹配换行,多行 JSON 抓不到。
补充一点:第 ① 步虽然也重要(带围栏的脏输出很常见),但它不算"兜底层"——它在 try 外面,是无条件执行的归一化。所以"三层兜底"和"四步阶梯"说的是同一段代码的两个视角,不矛盾。
2. 项目里 _items 解析失败时 raise ValueError,而 _to_score 失败时 return None。这看起来是两套不一致的风格。请判断到底是不是不一致,并说明如果把它们对调会怎样。
答案
不是不一致。判据是"失败之后,谁接得住"。
# _items 的调用现场(mcp_server.py:266-273)
try:
...
erp["sales"].create_order(..., _items(items_json)) # ← 抛在这里
return f"✓ 销售订单已创建 …"
except Exception as e:
return _deny(e) # ← 一步之外就被接住,转成给模型看的文本
_items 抛出的 ValueError,往上走一层就被工具体的 except Exception 接住,转成 ⛔ items_json 须为 JSON 数组 回到模型手里。raise 是故意的——因为这里有一个"工具边界",失败必须变成一段给模型看的文本。
而 _to_score 在判分链路上,那里没有一个工具边界来接异常,None 就是"这项没分"的合法表达。如果它抛异常,整个判分流程会中断——而"某一项没打上分"本来是可以继续的事。
_items | _to_score | |
|---|---|---|
| 失败动作 | raise ValueError | return None |
| 谁来接 | 工具体的 except Exception → _deny | 上层把 None 当"缺失" |
| 判据 | 失败必须变成一个给模型看的文本 | 失败必须变成一个可判定的缺失 |
如果对调会怎样?
_items改成return None:create_order会收到None当订单明细——要么内部报一个更难懂的TypeError,要么真的建了一张空订单。 更糟的是后者。_to_score改成raise:模型随便写个"高",整个判分任务当场中断——一个可以跳过的单项,变成了整批失败。
所以真正的规则是一条,不是两条:失败要变成"调用方接得住的那种形式"——有工具边界就变成文本,只有数据流就变成缺失。
进阶题
1. 第五部分给出的改进项是"把十处解析收敛成一个共用函数"。这件事听起来很简单(抽个函数而已),但真做起来会碰到一个硬问题。请指出这个硬问题,并给出你的处理方案。
答案
硬问题是:这十处的"兜底值"语义并不相同——阶梯能统一,"兜底给什么"统一不了。
把十处摊开看:
| 站点 | 解析对象 | 兜底给什么 | 兜底值的语义 |
|---|---|---|---|
_parse_classify | 意图分类 | ("simple", 原始query) | 一个可继续的默认分类 |
_parse_json_list | JSON 数组 | [] | 空集合 |
_extract_scores | 四项分数 | 逐项 None | 逐字段缺失 |
_parse_react_output | ReAct 三段 | 逐字段空串 | 逐字段缺失 |
_parse_plan_result | 计划 JSON | {} / 空计划 | 空对象 |
parse_conclusion | 根因结论 | code=None | 明确的"未归因" |
_extract_bool_json | 布尔字段 | None | 明确的"判不出" |
注意这两类兜底的本质差别:
- "给一个可继续的默认值"(
"simple"、[])——假装成功,让流程往下走; - "给一个明确的缺失"(
None)——承认失败,让上层来决定怎么办。
这两个方向不能混。 parse_conclusion 特意返回 code=None 并标注"不硬猜",而 _parse_classify 特意返回 "simple" 让流程继续——它们都是对的,但理由完全相反。
所以"共用一个函数"的正确形态是:
def parse_llm_json(text, *, fallback, on_miss=None):
"""
四步阶梯:剥围栏 → loads → 正则抓 {...} → 返回 fallback。
- fallback:调用方自己决定的"兜底值"(这正是不能共用的部分)
- on_miss :降级回调(打日志/计数),补上第五章那条缺口
"""
也就是说:能共用的是"阶梯"(怎么抓),不能共用的是"兜底"(抓不到给什么)与"降级怎么记"(谁被通知)。
这正是它的价值所在——改造过程会逼着每一处显式写下"我抓不到的时候想干什么"。而现在,这件事是隐含在代码分支里的,没人能一眼看全。
判据:一个好的抽象,不是消灭差异,而是把差异集中到必须显式声明的参数上。
思考题
1. 本项目没有使用 Pydantic(也没有 jsonschema)。请说出它换来的至少两条好处与付出的至少两条代价**;然后不要评判这个项目,而是给出你自己项目的取舍判断——你会怎么选,为什么?**
答案
这题没有标准答案,但有标准答法——下面给一个示范,重点是"判断结构"而不是"结论"。
本项目换来的(好处):
- 零额外依赖——
pydantic通常是带pydantic-core(Rust 扩展)的大包。这个项目要能在内网环境、在受限环境里直接跑起来,依赖越少,能不能跑起来的概率越高。 - 启动快、没有框架层——不需要在导入期构建模型类、解析注解。
- 代码就是普通 Python——没有"框架魔法"要学,新人读
_items四行就懂了。
本项目付出的(代价):
- 规则散在四处(
skill_framework.py:136/mcp_server.py:150/evalkit/judge.py:151/agentworkflow/rules.py:180),没有任何一处知道完整规则——新人得读四个文件才知道规则全貌。 - 类型检查靠人记得写——
_items那两行没人提醒你写;漏写不会报错,只会静默少一层校验。 - 没有统一的错误结构——校验失败靠返回字符串,调用方要自己判断格式。
- 校验器位置错了也不会有人告诉你——
DocSearchSkill继承了一个语义不匹配的黑名单,没有任何机制会提醒(这是 §4.3 那处误杀的成因)。
那怎么选? 建议按下面三个问题判:
| 问题 | 倾向"用框架" | 倾向"手写" |
|---|---|---|
| ① 校验的规则数量有多少? | 多(十几个字段、嵌套结构) | 少(本项目就是几个字段 + 几条正则) |
| ② 依赖的代价能不能承受? | 常规环境,无所谓 | 内网/离线/受限环境,每加一个依赖都要论证 |
| ③ 规则会不会反复变? | 会反复改 → 框架帮你集中管理 | 基本稳定 → 手写够用 |
最关键的判断不是"用不用 Pydantic",而是:
这套校验规则,有没有"一个能被改的地方"?
- 用 Pydantic = 把这个地方交给框架(规则集中在模型类里);
- 手写 = 必须自己造出这个"地方"(比如本章改进项里那个"共用校验层")。
本项目的问题从来不是"没用 Pydantic"——它的校验规则确实不多,手写合理。问题在于它没为"手写"配一个集中的落点,于是规则散开了。换句话说:如果不用框架,你可以不用;但你不能因此就不用"一个地方"。
本章小结
| 收获 | 内容 |
|---|---|
| 一个去魅 | 提示词里写"请以 JSON 格式输出",是软约束不是保证——三档保证里只有 grammar 约束解码是硬的,而本地链路拿不到它 |
| 一条链条 | ① 数据不出内网 → ② 只能用本地模型服务 → ③ 这条链路没有 grammar 约束解码 → ④ 只剩"提示约束 + 后置校验" |
| 一个阶梯 | 剥围栏 → 直接 loads → 正则抓 {...} → 兜底给一个值;最后一层必须给值,因为"解析失败"是降级不是故障 |
| 一个反驳 | "重试一次"不是答案——同模型、同提示词、同采样,没有理由给你不同的结果;名字叫 rewrite_retry 的那个提示词是第二轮检索用的,不是解析重试 |
| 一个规模 | 十处手写解析,层数 0–4 不等;四种范式,没有一种共享代码 |
| 一条铁律 | 参数结构与描述都只留一处来源——@mcp.tool() 从类型注解 + docstring 自动派生(单向),BaseSkill 的类属性是第二处来源 |
| 一处实证 | 同一个工具的描述写了两遍,而且漂移方向相反:计算器 3 句 vs 4 句(工具多一句算子白名单)、文档检索 3 句 vs 2 句(工具少一句适用场景) |
| 一个数字 | 24 个 @mcp.tool(),24 份 schema;而 docs/guides/digital_employee_architecture.md:41 那句"19 个业务工具"已经错了(手抄的清单不会自己更新) |
| 一个反直觉 | 继承来的是代码,不是语义——DocSearchSkill 继承了为计算器写的黑名单,实测误杀 open() 函数怎么用、漏放 eval 的替代写法 |
| 一个缺口 | 🔴 没有 Pydantic、没有 jsonschema、解析失败不重试——这不是省略,是要付代价的选择:零依赖 vs 规则散在四处 |
| 一个更该知道的缺口 | 解析兜底是完全静默的——本章涉及的 8 个源文件 logging. 零命中,4 个主要解析函数的兜底分支连 print 都没有 |
| 一个分化 | 字段级兜底 ≠ 枚举级校验——data.get("type", "simple") 会把拼错的 "chichat" 当缺字段,静默走错分支 |
| 一个收口 | 本章 9 个坑的成因是**"想当然"(Ch10 想不到 / Ch11 想省事 / Ch12 想当然)——所以药方是别再假设对方会配合你** |
下一章:Ch13 · LangGraph 状态图 —— 十三个节点、两个循环,以及一个问题:为什么别用一堆 if-else 去写一个 Agent。
本章解决的是"模型说的话怎么信";下一章要解决的是"整件事该怎么串起来"。
- 本章:兜底阶梯是"对一次输出结果的校验"——有
loads、有正则、有默认值; - 下一章:状态图是"对整体流程结构的编排"——有节点、有边、有条件分支。
这两章的连接点是同一件事的两面:本章关心"一个值可不可用",下一章关心"一个流程走不走得通"。 而它们共用同一个前提——上一环的输出,是下一环的输入。
本章的接口:本章不改变任何检索与生成行为——它一行检索代码没动、一个提示词没改。 它改变的是"你凭什么相信模型给你的那个值"。 所以它天生容易被跳过:兜底逻辑不产出功能,只产出"不崩"。而"不崩"在验收单上,永远是空的。 请记住第五部分那句:写不下来的约定,等于没有约定。 也请记住本章那句:最危险的降级,是那个"兜住了但兜歪了"的降级——它不报错,只是悄悄给了一个看起来正常的答案。
📌 一处口径说明(与分集正文的差异):本集没有实测数字——它不像 Ch11 有黄金集与 run 日志,全部证据都是结构量,每一个都能指回一条命令(见分集正文的"附录 A · 可复现的结构量")。 本讲义与分集正文有一处口径差异:分集正文把"解析兜底不打日志"记为"有几处兜底只
logging.全部零命中,而 4 个主要解析函数(_parse_classify/_parse_json_list/_parse_react_output/_parse_llm_intent)的兜底分支里连
导航:返回总目录 · 上一篇:大模型调参与效果评测 · 下一篇:LangGraph 状态图(v2 第 13 集,编号见 课程表_第06集起.md)