《AI Agent 开发入门》6.3 结构化输出约束与解析兜底

本节给 minagent 加上结构化输出:先讲 JSON mode 与 schema 约束的接口设计(只讲设计不真调),再把重点放在解析兜底上——实现 parse_json 稳健处理代码围栏、前置寒暄、嵌套括号等脏输出,用一组样例做单元测试并真跑,最后演示解析失败时把错误回灌给模型重出。

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 流式事件模型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距