6.3 结构化输出约束与解析兜底
Agent 里凡是「要让程序接手的输出」,都不该是自由文本。工具调用的参数是 JSON,计划是 JSON,评分是 JSON——第 4 章的 ToolRegistry 早就在依赖结构化输入了。但真实模型并不总是乖乖吐一个干净的 JSON 对象:它可能套上 Markdown 围栏、先说一句「好的」,甚至在末尾补一段解释。这一节给 minagent 加上解析兜底。
本节把 minagent 推进到「输出可被程序安全消费」:先厘清 JSON mode 与 schema 约束的接口设计,再实现一个能处理脏输出的
parse_json,用一组样例做单元测试,并在解析失败时把错误回灌给模型要求重出。
6.3.1 为什么必须结构化
自由文本对人类友好,对程序不友好。当 Agent 的下游是代码而非人时,输出必须是可解析的结构:
| 消费方 | 需要的形式 | 例子 |
|---|---|---|
| 工具调用 | JSON Schema 参数 | {"path": "/tmp/a.txt"} |
| 规划器 | 步骤数组 | [{"step": 1, "action": "search"}] |
| 评分器 | 带字段的对象 | {"score": 8, "reason": "..."} |
| 路由 | 枚举值 | {"route": "search"} |
问题在于,模型是按概率生成 token 的,不是「按 schema 序列化」。哪怕你要求它输出 JSON,它也有一定概率加上围栏、加解释、或在 JSON 里写注释。约束是降低出错率,不是消除出错——这就是为什么「解析兜底」和「输出约束」必须成对出现。
6.3.2 接口设计:JSON mode 与 schema 约束
不同供应商提供不同强度的约束,设计上可以归纳成三档:
| 档位 | 机制 | 保证 | 代价 |
|---|---|---|---|
| 提示层 | system 里写「只输出 JSON」 | 无硬保证 | 零成本 |
| JSON mode | 请求参数 response_format={"type": "json_object"} | 输出是合法 JSON | 部分供应商支持 |
| schema 约束 | 传 JSON Schema,模型按 schema 生成 | 字段与类型都对 | 供应商差异大、有延迟成本 |
设计要点是:把「约束强度」做成模型适配器的一个参数,而不是散落在业务代码里。这样同一个 Agent 既能跑在「只给提示」的弱约束后端上,也能跑在「给 schema」的强约束后端上,业务层无感。这正是 2.2 节「供应商适配器」接口的延伸。
本节不真调任何供应商的 JSON mode / schema 接口(那需要网络与额度),只讲接口形态。真正要跑的是解析兜底——因为无论约束多强,兜底都不能省。
minagent 的契约里,模型返回的是 ModelResponse.message.content(一个字符串)。约束是「怎么让它更像 JSON」,兜底是「拿到字符串后怎么稳地变成对象」,两者解耦:约束换供应商,兜底永远在自己手里。
6.3.3 脏输出的四种形态
真实模型吐出的「想当 JSON 但不是纯 JSON」的文本,主要有四类:
| 形态 | 例子 | 处理 |
|---|---|---|
| Markdown 围栏 | ```json\n{...}\n``` | 剥围栏 |
| 前置/后置寒暄 | 好的:{...} 希望有帮助 | 提取首个 JSON |
| 注释或尾逗号 | {...} // 结果 | 交给 json.loads 判定 |
| 真的非法 | {a: 1} 单引号键 | 无法修复,抛错重试 |
前两类是可救的(内容其实完整,只是包了层壳),第三类看具体情况,第四类是不可救的(必须让模型重出)。parse_json 的目标就是:能救的救回来,救不回的明确抛错,绝不返回一个「看起来像但其实是错的」对象。
最后一点很重要:宁可抛错,不可猜。如果解析器把 {a: 1} 猜成 {"a": 1},它就在替模型做决定,而那个决定可能错。让错误显式地冒出来、进入重试流程,比悄悄给一个错对象安全得多。
6.3.4 稳健解析 parse_json
# minagent/parse.py
from __future__ import annotations
import json
import re
from typing import Any
_FENCE_RE = re.compile(r"```(?:json|JSON)?\s*(.*?)```", re.DOTALL)
class ParseError(ValueError):
"""模型输出里找不到合法 JSON。"""
def strip_fence(text: str) -> str:
"""剥掉 Markdown 代码围栏;没有围栏时原样返回。"""
m = _FENCE_RE.search(text)
return m.group(1) if m else text
第一步是剥围栏。正则 ```(?:json|JSON)?\s*(.*?)``` 匹配「可选的语言标记 + 内容 + 闭合围栏」,re.DOTALL 让 . 能跨行匹配。(?:json|JSON)? 里的 ? 让它对「有语言」和「没语言」两种围栏都成立。
第二步是「提取首个配平的 JSON 对象」。难点在于字符串里的括号不能算数——{"note": "用 {花括号}"} 里的 { 不该增加嵌套深度。所以要逐字符扫描并跟踪「是否在字符串内」:
def _balanced(text: str, start: int) -> str | None:
"""从 start 处的 { 或 [ 起,扫到配平的右括号;字符串内的括号不计。"""
opener = text[start]
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
elif c == '"':
in_str = True
elif c in "{[":
depth += 1
elif c in "}]":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
状态机有三个变量:depth 记嵌套深度,in_str 记是否在字符串里,esc 记上一个字符是否是转义符。进入字符串后,只有未转义的 " 才能退出;字符串内的 {/} 一律不计。扫到 depth 回到 0 时返回这一段,找不到就返回 None。
把两步串起来就是 parse_json:
def parse_json(text: str) -> Any:
"""稳健解析:去围栏 → 直接 loads → 提取首个配平对象 → 抛 ParseError。"""
cleaned = strip_fence(text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
for i, c in enumerate(cleaned):
if c in "{[":
cand = _balanced(cleaned, i)
if cand is None:
continue
try:
return json.loads(cand)
except json.JSONDecodeError:
continue
raise ParseError("未能在输出中找到合法 JSON")
三层降级,由快到稳:① 直接 json.loads(干净输出的快路径);② 扫出首个配平片段再 loads(应对寒暄包裹);③ 都失败就抛 ParseError(进入重试)。注意第 ② 步用 enumerate 遍历所有 {/[ 起点,而不是只试第一个——万一第一个 { 在寒暄的比喻里、不是真正的 JSON 起点,还能继续往后找。
6.3.5 单元测试:一组脏输出样例
解析器是纯函数,最适合单元测试。构造一组覆盖各种脏形态的样例:
# run63.py
import sys
sys.path.insert(0, "/tmp/ab6work")
from minagent.parse import parse_json, ParseError
CASES = [
("干净 JSON", '{"city": "北京", "temp": 22}', {"city": "北京", "temp": 22}),
("带 json 围栏", '```json\n{"ok": true}\n```', {"ok": True}),
("无语言围栏", '```\n[1, 2, 3]\n```', [1, 2, 3]),
("前置寒暄", '好的,这是结果:{"score": 8},希望有帮助。', {"score": 8}),
("后置解释", '{"a": {"b": 1}} 这是嵌套对象。', {"a": {"b": 1}}),
("字符串里带括号", '{"note": "用 {花括号} 和 [方括号]"}', {"note": "用 {花括号} 和 [方括号]"}),
("前后都有噪声", '前言 {"k": "v"} 后记', {"k": "v"}),
]
passed = 0
for name, raw, expect in CASES:
try:
got = parse_json(raw)
ok = got == expect
except ParseError as e:
got, ok = f"ParseError: {e}", False
passed += ok
print(f"[{'PASS' if ok else 'FAIL'}] {name:14} -> {got}")
# 真·非法输入必须抛错,而不是返回半个对象
for name, raw in [("残缺 JSON", '{"a": 1'), ("单引号键", "{'a': 1}"), ("纯文本", "我不确定。")]:
try:
got = parse_json(raw)
print(f"[FAIL] {name:14} -> 意外返回 {got}")
except ParseError:
passed += 1
print(f"[PASS] {name:14} -> 正确抛 ParseError")
print(f"\n{passed}/{len(CASES) + 3} 通过")
真跑输出:
[PASS] 干净 JSON -> {'city': '北京', 'temp': 22}
[PASS] 带 json 围栏 -> {'ok': True}
[PASS] 无语言围栏 -> [1, 2, 3]
[PASS] 前置寒暄 -> {'score': 8}
[PASS] 后置解释 -> {'a': {'b': 1}}
[PASS] 字符串里带括号 -> {'note': '用 {花括号} 和 [方括号]'}
[PASS] 前后都有噪声 -> {'k': 'v'}
[PASS] 残缺 JSON -> 正确抛 ParseError
[PASS] 单引号键 -> 正确抛 ParseError
[PASS] 纯文本 -> 正确抛 ParseError
10/10 通过
10/10 通过。最值得看的两条:字符串里带括号 验证了状态机没被字符串里的 {/[ 带偏;单引号键 和 残缺 JSON 验证了解析器没有自作聪明——它们本可以「猜」成合法对象,但正确做法是抛错。这正是 6.3.3 说的「宁可抛错,不可猜」。
6.3.6 解析失败后的兜底:报错回灌
ParseError 不是终点,而是重试的起点。把错误信息回灌给模型,让它重出:
# run63b.py
import sys
sys.path.insert(0, "/tmp/ab6work")
from minagent.types import Message, ModelResponse
from minagent.models import ScriptedModel
from minagent.parse import parse_json, ParseError
def repair(model, messages, tools=None):
"""解析失败时,把错误回灌给模型要求重出,最多一轮。"""
resp = model.complete(messages, tools or [])
try:
return parse_json(resp.message.content)
except ParseError as e:
messages = messages + [
resp.message,
Message(role="user", content=f"你上次的输出无法解析为 JSON:{e}。请只输出合法 JSON。"),
]
retry = model.complete(messages, tools or [])
return parse_json(retry.message.content)
model = ScriptedModel([
ModelResponse(message=Message(role="assistant", content="当然可以!结果是:{score: 8}")), # 非法
ModelResponse(message=Message(role="assistant", content='{"score": 8}')), # 修正
])
msgs = [Message(role="system", content="只输出 JSON。"),
Message(role="user", content="给这份周报打个分。")]
print("修复结果:", repair(model, msgs))
print("模型被调用了", len(model.calls), "次")
print("第二次请求的最后一条:", model.calls[-1][0][-1].content)
真跑输出:
修复结果: {'score': 8}
模型被调用了 2 次
第二次请求的最后一条: 你上次的输出无法解析为 JSON:未能在输出中找到合法 JSON。请只输出合法 JSON。
第一次模型吐了 {score: 8}(键没引号,非法),parse_json 抛错;repair 把错误原文连同「请只输出合法 JSON」追加进消息,第二次模型给出合法 JSON,解析成功。这里的关键是把错误信息一起回灌:只说「重来」模型可能再犯,说清「你哪里错了」它才知道怎么改。
注意 repair 设了上限一轮——重试不能无限。第 14 章会把「解析失败」归类成可重试错误,接上完整的重试与退避策略;本节只做最小可用的一次回灌。
6.3.7 边界与取舍
- 约束降概率,兜底保底线。别指望 JSON mode 就万事大吉,兜底永远要在。
- 宁可抛错,不可猜。单引号、缺引号这类「看起来能修」的,交给模型重出更安全。
- 重试要有上限。一次回灌通常够;连续失败说明约束或提示词有问题,该改的是提示而非无限重试。
- 回灌要带错误原文。
ParseError的 message 要具体(比如「未能在输出中找到合法 JSON」),别只回一个「解析失败」。 - schema 校验在解析之后。
parse_json只保证「是合法 JSON」,字段齐不齐、类型对不对是下一步——第 4 章的参数校验干的就是这件事。
有一条边界要划清:parse_json 不负责语义正确。它能保证「{"score": 8} 是合法 JSON」,但保证不了「8 是个合理的分数」。前者是解析,后者是校验与评测,分属不同层。
真实模型下会怎样
真实模型在有 JSON mode / schema 约束时,脏输出比例会明显下降,但不会归零——尤其是「前置寒暄」这类,很多模型即使被要求「只输出 JSON」也爱先说一句。所以 parse_json 的价值在真实环境里只增不减。两个真实差异:第一,真实输出的嵌套更深、字符串里更可能含转义引号,_balanced 的转义处理必须严谨;第二,真实重试有成本与延迟,回灌一次就多一次调用,所以约束越强、重试越少,整体越省。本节的 parse_json 是一个零依赖、纯函数、可单元测试的骨架,它和第 4 章的参数校验一起,构成 minagent 对「程序化输出」的两道关卡。
阅读导航:上一节:6.2 上下文裁剪与摘要 · 下一节:7.1 流式事件模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。