结构化输出

大模型结构化输出:从 JSON 契约到 Function Calling 落地

本文主线:先看“只靠 Prompt 要 JSON”为什么不稳,再看怎么用 Schema 把输出变成契约,最后落到 Function Calling、MCP 和 Java 后端工具执行。

在如今 Agent 工作量中,需要大模型频繁地调用各类工具以跳脱出原始的对话框,当前的 Agent 也越来越依赖结构化输出,能够稳定、可靠地输出各种形式的结构化内容,意味着 Agent 对于工具的掌握就越熟练。

为了让大模型输出严格的结构化内容,让大模型在自回归生成时就遵守结构规范是一种符合直觉的方案,但在我们实际的经验中,大模型会频繁地不按照我们的要求进行,所以除了在大模型本身下功夫外,还需要在外部进行干预。

prompt 为什么不能强制规范结构化输出

底层原因:大模型自身性能不足幻觉上下文污染

具体表现为:

  1. 格式漂移:确实输出了 JSON,但是不只有 JSON,还有一些自然语言,比如 “以下是…”,或者在最后解释 “上述 JSON 是……” 等,如果直接返回给后端,光是自然语言这几句话就直接让服务爆了。

  2. 字段缺失:JSON 缺失一些字段,有可能是因为大模型不确定字段对不对,所以干脆不输出了。

  3. 类型错误:例如布尔类型 true 写成字符串 "true",给后端一样爆了。

  4. prompt 造成的放弃 JSON:比如用户一句 “不要用 JSON 输出”,模型就真的放弃输出 JSON 了。

总结:prompt 不能强制模型结构化输出,也不可信。

不过,尽管光靠 prompt 不能强制结构化输出,但是根据 Open AI 文档,需要 JSON 结构化输出时,提示词里仍然需要明确模型按照 JSON 输出。

为模型加入工程规范:以最常见的 JSON 为例

结构化输出最常见的三个规范: JSON ModeJSON SchemaStructured Output,三种并非同一维度的线性增强,而是三种维度。

JSON Mode

JSON Mode 是为了解决上面提到的 格式漂移 问题的,也就是 让模型输出合法 JSON

然而,这个功能无法保证输出的格式、字段、结构符合要求。

JSON Schema

JSON Schema 是给 LLM 看的,一个工具对应一个 Schema,LLM 可以按需调用,并且从中得到关键信息:工具用途、工具的调用时机、如何调用该工具、需要哪些字段等。

JSON Schema 并非是一种输出模式,它本质上只是一个模板,规定了模型输出必须以何种格式、包含哪些字段的 JSON 模板,该模板让模型以 JSON Mode 输出时,必须百分百按照该模板输出 JSON。

一个规范的 Schema 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": [
"PAYMENT",
"LOGISTICS",
"AFTER_SALE",
"ACCOUNT",
"NEED_MORE_INFO"
],
"description": "工单分类。信息不足时选择 NEED_MORE_INFO。"
},
"priority": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH"],
"description": "处理优先级。涉及资金损失、无法下单、批量影响时优先级更高。"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "分类置信度,范围为 0 到 1。"
},
"reason": {
"type": "string",
"description": "分类依据,控制在 80 个中文字符以内。"
}
},
"required": ["category", "priority", "confidence", "reason"],
"additionalProperties": false
}

然而,单纯的 JSON Schema 是死的,其本身是无法发挥作用的,我们要想让其体现到模型的输出,必须依赖某种手段将其安插到模型生成过程中——也就是下面的 Structured Output 模式。

Structured Output

Structured Output = JSON Mode + JSON Schema + 底层约束。即 strict: true

注意其中的底层约束,到底底层在哪?我们说,大模型本质是根据历史 token 预测下一 token,自回归生成的过程就是最底层的过程,而 Structured Output 的底层约束就是在此阶段,约束所有候选 token 。比如说,某个字段的值的类型是整型,那么在生成该字段的下一 token 时,直接把所有非整型的候选 token 的概率全部清零,那么采样到的被选 token 就必然是整型了。

另外,这个过程需要另一个校验引擎来干预模型生成,不然全部交给模型自行校验自行抹除,太不可靠。

Function Calling / Tool Use

这个也同样是结构化输出,但是是面对特定工具、方法的输入。比如 SQL 数据库,模型就必须输出合法的 sql 语句以控制数据库;比如 elasticsearch,模型就必须输入合法的 es 查询语句。

大模型调用工具的流程

大概的流程:

1
2
3
4
5
6
7
8
   用户自然语言prompt
=> 模型判断是否需要调用工具
=> 需要调用工具,就让模型生成 `工具调用意图`,包括需要调用哪个工具(需要后端服务器定义工具调用接口)、需要哪些参数等
=> 模型将调用工具的命令的结构化输出给后端服务器,自己先阻塞
=> 服务器拿到工具调用,需要校验结构化输出是否合法(所有结构化输出的必经之路)
=> 服务器翻译命令然后发给工具,执行命令
=> 服务器拿到工具调用结果,再把结果传回模型
=> 模型被唤醒后,根据工具调用结果生成最终回答

工具意图

前面提到,后端服务需要定义工具调用的接口,在调用 llm 的 组装 prompt 阶段,这些定义就会被加入到 prompt 中,一步步传给大模型。假如用户此时输入了一串简短的提示词,想让大模型查询一下天气:

1
查询一下重庆的天气。

那么真正传给大模型后,大模型面对的提示词会变成:

1
2
3
4
5
[System]: 你是一个得力的助手。你有以下工具可用:
get_weather(location: string): 获取指定城市的天气。
query_database(sql: string): 执行 SQL 查询。
如果你需要使用工具,请按照严格的 JSON 格式输出 tool_calls。
[User]: 查询一下重庆的天气。

面对这样一串 prompt,大模型会敏锐地意识到:我可能需要调用工具。然后,不可思议的事情发生了:模型在自回归生成的过程中,会自发地让结构化的 JSON 语句得到更高的分数,自然语言文本得到更低的分数,如此一来就更容易输出结构化 JSON 语句。

然后,大模型可能会输出这样的结构化输出:

1
2
3
4
5
6
{
"name": "get_weather",
"arguments": {
"city": "Chongqing, China"
}
}

这也就是所谓的工具意图,这种能力是通过大量的监督微调得来的。

HTTP API & MCP Tool & Agent Skill

Function Calling 映射到 http api

HTTP API,就是 open feign 那种用 http 协议远程调用的服务。

使用中间层转译。

1
模型输出结构化输出 `get_order` => 中间层校验并转译成 http 接口 => 后端调用 `GET /api/weathear/"Chongqing, China"`

当然,虽然可以直接让模型输出 GET /api/weathear/"Chongqing, China",但是不安全,我们希望模型输出同一套 function calling 的结构化输出,并在后端处理往哪路由。

MCP 跟 Function Calling 的区别

Function Calling 是模型供应商为模型提供的能力,是让 模型自主决定调用什么工具 ,调用的工具也局限于我们后端服务器能够接触到的工具,也就是在后端服务器内自行定义的接口。

这样在服务器内部硬编码的接口,很难说可以复用;此外专注工具的调用也是一笔性能花销。于是我们很自然地想到,将服务器内部硬编码定义的 接口列表调用工具 这两个功能解耦成一个独立的服务器,我们的业务服务器就不需要自行定义接口,也不用亲自调用工具了。

这个独立服务器应当首先包含以下能力:

  • 工具列表暴露:当不同的业务服务器访问自身时,会将内部定义的工具接口自动发送到后端业务服务器;

  • 真正调用工具:代替业务服务器,响应大模型发来的 function calling,转译成 http api ,然后执行工具,再将工具结果响应给大模型;

有了这两个能力,业务服务器就能够解法出来,独立服务器还可以共享给多台业务服务器,这就是 MCP 服务器。

除了上述能力,MCP 服务器也要提供作为一个服务器该有的基础能力,比如鉴权、限流、熔断降级;另外,MCP 服务器因为面向的是大模型,也要为大模型提供调用大模型的业务服务器的一些上下文信息,比如库表结构、内部 api 文档等信息,便于大模型精确推理。

一个调用 MCP 服务器的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[用户] "读一下 main.py"


[AI Client (如 Cursor)] ───(识别意图)───► [大模型 (LLM)]
│ │
│◄───(返回 Tool Call: read_file("/app/main.py")) ──┘

│ (网关拦截,通过 MCP 协议转发请求)

[MCP Server] ───(在本地读取 main.py 文件)───► [本地文件系统]
│ │
│◄───(返回文件真实内容) ───────────────────────────┘

│ (将读取的内容转成标准 MCP 格式返回)

[AI Client] ───(把文件内容喂给大模型)───► [大模型 (LLM)]
│ │
│◄───(整理出人能看懂的分析报告) ──────────────┘

[用户] "这是 main.py 的内容..."

当然,这个时候 MCP 服务器是部署在本地的,不然云端 MCP 读不到本地文件。

Skill

Skill 是用来编排大模型工作流程的文档,不是 Function Calling 的语法糖。

Strctured Output、Function Calling/Tool Use、MCP 什么时候用?

不管是最开始为了便于后端 parse 而需要的 Structured Output,还是让模型开始有了访问外界的能力的 Function Calling,抑或是让模型访问工具变得更便捷也更安全的 MCP,尽管三者从技术角度有着本质区别,但从目的上三种本质讲的都是同一件事情:怎么让大模型能够稳定地与传统软件系统进行交互。

根据不同业务的需求,也需要做出不同的

怎么在 Function Calling 中落地结构化输出

在实际生产环境中设计一个结构化输出,要涉及相当多的方面,Schema 本身设计的严谨程度只是第一步,假如 Schema 在后续业务中发生变动应当怎么兼容各个版本?这些都要在 Schema 设计时都要考虑到。

Schema 设计

  1. 一个字段只代表一个原子含义,例如:

    1
    2
    3
    {
    "result": "支付问题,高优先级,需要人工处理"
    }

    result 一句话说完,固然方便省事,但完全不利于后端

    应当被解耦为:

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "result":{
    "category": "PAYMENT",
    "priority": "HIGH",
    "needManualReview": true,
    "reason": "用户已支付但订单状态未同步"
    }
    }

每个字段都只描述一件事。

  1. 枚举优于自然语言。为了方便后端校验,非自然语言描述的各个字段(比如resaon就不是,需要人工校验),其值最好也是枚举的。例如 problem_level,最好只有 LOW MEDIUM HIGH 三种状态。

  2. 字段的取值需要详细说明。就比如 problem_level 的三种状态,LOW MIDIUM HIGH 分别是什么时候取这个值?要给 LLM 一个明确的说法。比如:

    1
    2
    3
    4
    5
    6
    7
    {
    "problem_level":{
    "type":"string",
    "enum" : ["LOW", "MEDIEM", "HIGH"],
    "value": "问题严重程度。如果问题会导致服务器崩溃,选择HIGH;如果服务器不会崩溃,但部分业务完全停摆,选择MEDIUM;如果"
    }
    }
  3. Schema 也要有版本号。

  4. boolean 类型天然强于 yes 等自然语言,对于强调是与否的字段,一定使用布尔类型。

生产环境中的输出规范

  1. 一旦结构化输出到工具时执行失败,先让 LLM 分析错误原因,如果是结构化输出的问题,一定要让 LLM 继续调整结构化输出

  2. 保证必填字段真的填了。比如在一个生产案例中,某个对象的某个字段值为空,那么是否说明 LLM 的结构化输出可以不要这个字段?非也,这个字段必须存在,只是要在值上给明为 null。比如:

1
2
3
4
5
6
{
"obj":{
"seg1" : "segment",
"seg2": null
}
}

而非:

1
2
3
4
5
{
"obj":{
"seg1" : "segment"
}
}
  1. 还是结构化输出失败的问题,如果工具调用失败,后端应当兜底。可以适当降级、可以人工干预、甚至可以主动报“系统繁忙”拒绝响应,但绝对不能让 LLM 编造事实。

工具调用安全

举一个例子,删除某个字段这种风险操作,例如:

1
2
3
4
{
"action" : "delete",
"orderId" : 12324654736195123
}

其风险在于:1.LLM 如何保证 orderId 是业务直接相关的?假如给错了怎么办?2.如何确保 LLM 当前有权限进行风险操作?3.最重要的是,如果数据出现安全问题,谁负责,谁兜底?

后端多方检验

首先第一点问题,如何保证 LLM 的给出的字段完全符合业务,没有任何偏差?

答案是:后端从多方引入可靠数据源联合校验,如果与业务冲突,那就让 LLM 重试,或是和上面提到的:先让 LLM 重新调整结构化输出,然后重试工具。

后端不是帮 LLM 完成失败的业务,而是拦截失败的业务并提醒 LLM 重新执行业务。

风险操作避免 LLM 直接进行

这个主要解决第二点问题。

需要让用户或后端进行二次校验,LLM 只是 建议 而非直接操作,最后执行的仍然是用户和后端。

后端追踪

  1. 日志,建议记录:建议记录:
  • 用户输入。
  • 命中的工具名。
  • 模型生成的参数。
  • 服务端校验结果。
  • 真实执行的业务请求。
  • 工具返回结果。
  • 最终回复。
  • traceId、userId、tenantId、schemaVersion、model。
  1. 做幂等,整个工具意图调用链条中,任何一环都有可能重试,既然重试就必须考虑幂等性,针对任何写操作,必须严格做幂等

  2. 超时重试