Agent 结构化输出 —— 让 LLM 返回可校验的 JSON,而非自由文本

前置阅读:Agent 容错机制 | 系列目录 核心增量:Structure.py(~140 行)+ run_agent_with_trace 中 10 行集成


一、问题:自由文本不可靠

前几篇文章的 Agent 有一个隐含的假设:LLM 的输出是给人类看的自然语言 。但在很多场景下,Agent 的输出需要被程序消费

css 复制代码
场景 A: Agent 查询天气 → 下游系统需要 {city, temp, condition} 结构化数据
  → 用正则从"北京今天晴天,25°C"中提取?做不到准确率 100%

场景 B: Agent 执行数据分析 → 前端需要 JSON 渲染图表
  → LLM 偶尔会在 JSON 前后加 "这是结果:" 之类的废话,解析失败

场景 C: Agent 输出的文本中混入了 Markdown 或 HTML
  → 下游解析器崩溃

核心矛盾:LLM 最擅长的是自然语言,但程序需要的是结构化、可校验、可序列化的数据。直接用自由文本当输出,相当于把"解析"的责任推给了调用方------每一次都是 ad-hoc 的正则匹配和 try-catch。


二、方案:用一个特殊的 Tool 来输出

思路很直接------把"输出"也变成一个工具

flowchart TB subgraph OLD[之前:自由文本输出] O1[LLM 生成文本] --> O2[return msg.content] O2 --> O3[调用方自己解析] O3 --> O4[正则 / try-catch / 猜测] end subgraph NEW[之后:结构化输出] N1[LLM 调用 final_output 工具] N1 --> N2{参数符合 Schema?} N2 -->|是| N3[返回结构化 JSON] N2 -->|否| N4[json schema 校验失败] N3 --> N5[调用方直接 JSON.parse] end

不是让 LLM "以 JSON 格式回答"(prompt 约束不可靠),而是给一个必须填参数的 tool ,LLM 调用这个 tool 的行为天然就是结构化的------因为 tool 的 parameters 就是 JSON Schema。


三、最简单的实现:final_output 工具

python 复制代码
OUTPUT_TOOL_NAMES = {"final_output"}

def make_final_output_tool() -> dict:
    return {
        "type": "function",
        "function": {
            "name": "final_output",
            "description": (
                "以结构化格式输出最终答案。调用此工具表示回答完成。"
                "result 字段放结构化数据,summary 字段放给用户看的总结。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "result": {
                        "type": "object",
                        "description": "最终答案的 JSON 结构化数据",
                    },
                    "summary": {
                        "type": "string",
                        "description": "给用户看的一句话总结",
                    },
                },
                "required": ["result"],
            },
        },
    }

def final_output_handler(result: dict, summary: str = "") -> str:
    sanitize_output(result)
    if summary:
        sanitize_string(summary)
    return json.dumps({"result": result, "summary": summary}, ensure_ascii=False)

final_output 是一个信号工具------它不执行任何 I/O 操作,唯一的作用是告诉 Agent 循环"回答完了,这是答案"。

主循环中的终止逻辑

python 复制代码
for tc in tool_calls:
    name = tc["function"]["name"]
    args = json.loads(tc["function"]["arguments"])
    result = call_with_timeout(active_tool_map[name], kwargs=args, timeout=30)

    # ← 新增:final_output 是最终答案,直接返回
    if name in OUTPUT_TOOL_NAMES:
        return result    # ← 短路,不再循环

之前判断"LLM 是否回答完了"靠的是 if tool_calls is None------但这个信号不可靠。用 final_output 工具作为显式的"我完成了"信号,比隐式的"没有 tool_call"更准确。


四、三层安全防线

单纯靠 final_output 的工具 Schema 约束还不够------LLM 仍然可能在 result 字段中填入恶意文本。Structure.py 提供了三层防护:

flowchart TD IN[LLM 调用 final_output] --> L1 subgraph L1[第一层: Schema 安全校验] S1[校验 description 长度 < 200] S2[校验 properties 数量 < 50] S3[校验嵌套深度 < 5] S4[拒绝递归 schema] end L1 --> L2 subgraph L2[第二层: 输出格式校验] V1[jsonschema.validate 严格模式] V2[禁止额外字段 additionalProperties: false] end L2 --> L3 subgraph L3[第三层: 注入检测] I1[检测 Markdown 注入 - 代码块] I2[检测 HTML 注入 - script 标签] I3[检测 prompt 注入 - ignore 指令] I4[检测 SQL 注入 - DROP TABLE] end L3 --> OUT[安全的结构化 JSON 输出]

4.1 第一层:Schema 安全校验

防止恶意构造的 Schema 攻击校验器本身------描述太长可能是注入 payload,嵌套太深可能炸递归:

python 复制代码
MAX_SCHEMA_DEPTH = 5
MAX_DESCRIPTION_LEN = 200
MAX_PROPERTIES = 50

def validate_schema(schema: dict, depth: int = 0):
    if depth > MAX_SCHEMA_DEPTH:
        raise ValueError("Schema 嵌套过深")
    for key, prop in schema.get("properties", {}).items():
        if len(prop.get("description", "")) > MAX_DESCRIPTION_LEN:
            raise ValueError(f"description 过长: {key}")
        if prop.get("type") == "object":
            validate_schema(prop, depth + 1)

4.2 第二层:jsonschema 严格校验

LLM 的输出即使工具参数格式正确,也未必符合期望的 Schema。比如要求 temperaturenumber,LLM 却输出字符串 "25"

python 复制代码
def validate_output(data: dict, schema: dict) -> dict:
    jsonschema.validate(instance=data, schema=schema,
                        format_checker=jsonschema.FormatChecker())
    return data

4.3 第三层:注入检测

递归遍历整个输出结构,对每个字符串值执行注入检测------不管在顶层、嵌套对象还是数组元素里:

python 复制代码
INJECTION_PATTERNS = [
    (r"```", "Markdown 代码块"),
    (r"<script", "HTML script 标签"),
    (r"ignore.*previous.*instruction", "Prompt 注入"),
    (r"DROP\s+TABLE", "SQL 注入"),
]

def sanitize_output(data):
    if isinstance(data, str):
        for pattern, label in INJECTION_PATTERNS:
            if re.search(pattern, data, re.IGNORECASE):
                raise ValueError(f"检测到可疑内容 ({label}): {data[:80]}...")
    elif isinstance(data, dict):
        for v in data.values(): sanitize_output(v)
    elif isinstance(data, list):
        for item in data: sanitize_output(item)
    return data

五、工厂函数:为不同场景定制输出 Schema

Structure.py 还提供了一个工厂函数,让不同场景注册各自的输出工具:

python 复制代码
def make_final_output_tool(name, description, output_schema):
    validate_schema(output_schema)  # 第一层

    tool_def = {
        "type": "function",
        "function": {
            "name": name, "description": description,
            "parameters": {
                "type": "object",
                "properties": {
                    "data": output_schema,
                    "summary": {"type": "string", "description": "一句话总结"},
                },
                "required": ["data"],
            },
        },
    }

    def handler(data: dict, summary: str = "") -> str:
        validate_output(data, output_schema)  # 第二层
        sanitize_output(data)                 # 第三层
        return json.dumps({"result": data, "summary": summary}, ensure_ascii=False)

    return tool_def, handler

使用示例------注册一个天气查询输出 Skill:

python 复制代码
weather_tool, weather_handler = make_final_output_tool(
    name="output_weather",
    description="输出天气查询的结构化结果",
    output_schema={
        "type": "object",
        "properties": {
            "city": {"type": "string"},
            "condition": {"type": "string"},
            "temperature": {"type": "number"},
        },
        "required": ["city", "condition", "temperature"],
    },
)
skills.register(Skill(
    name="weather-output",
    tools=[weather_tool],
    tool_map={"output_weather": weather_handler},
    system_prompt="查询天气后必须调用 output_weather,不要直接返回文本。",
))

通过 Skill 注册,Agent 动态加载 weather-output 后,LLM 严格生成符合 Schema 的数据。


六、完整数据流

sequenceDiagram participant U as 用户 participant RA as run_agent_with_trace participant LLM as LLM participant SM as SkillManager participant ST as Structure 三层校验 participant OUT as 下游系统 U->>RA: &#34;北京天气怎么样?&#34; RA->>SM: get_active_tools() SM-->>RA: [search_web, final_output] RA->>LLM: messages + tools LLM-->>RA: tool_call: search_web(&#34;北京&#34;) RA->>RA: 执行 search_web RA->>LLM: messages + 搜索结果 LLM-->>RA: tool_call: final_output(result, summary) Note over RA,ST: 输出工具触发终止 RA->>ST: sanitize_output(result) ST->>ST: 递归检测所有字符串值 ST-->>RA: 安全的结果 RA->>OUT: JSON 结构化数据 OUT-->>U: 图表渲染 / 数据存储

七、设计精要

7.1 以 Tool 为输出载体,而非 Prompt 约束

Prompt "请以 JSON 格式回答" 是不可靠的。但当输出是一个 tool 的参数时,LLM 天然受 JSON Schema 约束------API 层面就要求参数是合法 JSON。

7.2 显式终止信号优于隐式判断

if name in OUTPUT_TOOL_NAMESif tool_calls is None 更明确。前者是"Agent 主动说完了",后者是"Agent 没话说了"。

7.3 安全是分层递进的

三层校验各司其职:Schema 校验防攻击者、jsonschema 校验防格式错误、注入检测防恶意内容。任何一层出问题都能精确定位。

7.4 零侵入集成

主循环只加了一行判断 + 一个提前返回,前序六层完全不受影响。


八、完整演进路径

yaml 复制代码
Phase 1: 基础 Agent               (~50 行)
Phase 2: 可观测 + 压缩 + 持久化     (+~200 行)
Phase 3: Skill 渐进加载            (+~150 行)
Phase 4: 多轮对话循环              (+~120 行)
Phase 5: 流式输出                  (+~80 行)
Phase 6: 重试 + 超时               (+~128 行)
Phase 7: 结构化输出                (+~140 行)  ← 本文

推荐学习资源

  • 从零开始打造一个AI Agent CLI --- 手把手带你构建一个完整的 AI Agent 命令行工具,从工具调用、对话管理到发布上线的全流程实战。
  • AI Agents 开发实践 --- 涵盖 Agent 架构设计、记忆系统、多 Agent 协作等核心主题的实战教程,适合想深入 Agent 开发的工程师。
  • AI 全栈编程生存指南 --- AI 时代的全栈开发者生存法则,从 AI 辅助编程到底层原理,帮你建立不可替代的技术壁垒。