JavaGuide - 结构化输出
结构化输出
大模型结构化输出:从 JSON 契约到 Function Calling 落地
本文主线:先看“只靠 Prompt 要 JSON”为什么不稳,再看怎么用 Schema 把输出变成契约,最后落到 Function Calling、MCP 和 Java 后端工具执行。
在如今 Agent 工作量中,需要大模型频繁地调用各类工具以跳脱出原始的对话框,当前的 Agent 也越来越依赖结构化输出,能够稳定、可靠地输出各种形式的结构化内容,意味着 Agent 对于工具的掌握就越熟练。
为了让大模型输出严格的结构化内容,让大模型在自回归生成时就遵守结构规范是一种符合直觉的方案,但在我们实际的经验中,大模型会频繁地不按照我们的要求进行,所以除了在大模型本身下功夫外,还需要在外部进行干预。
prompt 为什么不能强制规范结构化输出
底层原因:大模型自身性能不足、幻觉、上下文污染。
具体表现为:
格式漂移:确实输出了 JSON,但是不只有 JSON,还有一些自然语言,比如 “以下是…”,或者在最后解释 “上述 JSON 是……” 等,如果直接返回给后端,光是自然语言这几句话就直接让服务爆了。
字段缺失:JSON 缺失一些字段,有可能是因为大模型不确定字段对不对,所以干脆不输出了。
类型错误:例如布尔类型
true写成字符串"true",给后端一样爆了。prompt 造成的放弃 JSON:比如用户一句 “不要用 JSON 输出”,模型就真的放弃输出 JSON 了。
总结:prompt 不能强制模型结构化输出,也不可信。
不过,尽管光靠 prompt 不能强制结构化输出,但是根据 Open AI 文档,需要 JSON 结构化输出时,提示词里仍然需要明确模型按照 JSON 输出。
为模型加入工程规范:以最常见的 JSON 为例
结构化输出最常见的三个规范: JSON Mode、JSON Schema 和 Structured Output,三种并非同一维度的线性增强,而是三种维度。
JSON Mode
JSON Mode 是为了解决上面提到的 格式漂移 问题的,也就是 让模型输出合法 JSON。
然而,这个功能无法保证输出的格式、字段、结构符合要求。
JSON Schema
JSON Schema 是给 LLM 看的,一个工具对应一个 Schema,LLM 可以按需调用,并且从中得到关键信息:工具用途、工具的调用时机、如何调用该工具、需要哪些字段等。
JSON Schema 并非是一种输出模式,它本质上只是一个模板,规定了模型输出必须以何种格式、包含哪些字段的 JSON 模板,该模板让模型以 JSON Mode 输出时,必须百分百按照该模板输出 JSON。
一个规范的 Schema 如下:
1 | { |
然而,单纯的 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 | 用户自然语言prompt |
工具意图
前面提到,后端服务需要定义工具调用的接口,在调用 llm 的 组装 prompt 阶段,这些定义就会被加入到 prompt 中,一步步传给大模型。假如用户此时输入了一串简短的提示词,想让大模型查询一下天气:
1 | 查询一下重庆的天气。 |
那么真正传给大模型后,大模型面对的提示词会变成:
1 | [System]: 你是一个得力的助手。你有以下工具可用: |
面对这样一串 prompt,大模型会敏锐地意识到:我可能需要调用工具。然后,不可思议的事情发生了:模型在自回归生成的过程中,会自发地让结构化的 JSON 语句得到更高的分数,自然语言文本得到更低的分数,如此一来就更容易输出结构化 JSON 语句。
然后,大模型可能会输出这样的结构化输出:
1 | { |
这也就是所谓的工具意图,这种能力是通过大量的监督微调得来的。
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 | [用户] "读一下 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
2
3{
"result": "支付问题,高优先级,需要人工处理"
}result一句话说完,固然方便省事,但完全不利于后端应当被解耦为:
1
2
3
4
5
6
7
8{
"result":{
"category": "PAYMENT",
"priority": "HIGH",
"needManualReview": true,
"reason": "用户已支付但订单状态未同步"
}
}
每个字段都只描述一件事。
枚举优于自然语言。为了方便后端校验,非自然语言描述的各个字段(比如resaon就不是,需要人工校验),其值最好也是枚举的。例如problem_level,最好只有LOWMEDIUMHIGH三种状态。字段的取值需要详细说明。就比如
problem_level的三种状态,LOWMIDIUMHIGH分别是什么时候取这个值?要给 LLM 一个明确的说法。比如:1
2
3
4
5
6
7{
"problem_level":{
"type":"string",
"enum" : ["LOW", "MEDIEM", "HIGH"],
"value": "问题严重程度。如果问题会导致服务器崩溃,选择HIGH;如果服务器不会崩溃,但部分业务完全停摆,选择MEDIUM;如果"
}
}Schema 也要有版本号。
boolean类型天然强于yes、对等自然语言,对于强调是与否的字段,一定使用布尔类型。
生产环境中的输出规范
一旦结构化输出到工具时执行失败,先让 LLM 分析错误原因,如果是结构化输出的问题,一定要让 LLM 继续调整结构化输出
保证必填字段真的填了。比如在一个生产案例中,某个对象的某个字段值为空,那么是否说明 LLM 的结构化输出可以不要这个字段?非也,这个字段必须存在,只是要在值上给明为
null。比如:
1 | { |
而非:
1 | { |
- 还是结构化输出失败的问题,如果工具调用失败,后端应当兜底。可以适当降级、可以人工干预、甚至可以主动报“系统繁忙”拒绝响应,但绝对不能让 LLM 编造事实。
工具调用安全
举一个例子,删除某个字段这种风险操作,例如:
1 | { |
其风险在于:1.LLM 如何保证 orderId 是业务直接相关的?假如给错了怎么办?2.如何确保 LLM 当前有权限进行风险操作?3.最重要的是,如果数据出现安全问题,谁负责,谁兜底?
后端多方检验
首先第一点问题,如何保证 LLM 的给出的字段完全符合业务,没有任何偏差?
答案是:后端从多方引入可靠数据源联合校验,如果与业务冲突,那就让 LLM 重试,或是和上面提到的:先让 LLM 重新调整结构化输出,然后重试工具。
后端不是帮 LLM 完成失败的业务,而是拦截失败的业务并提醒 LLM 重新执行业务。
风险操作避免 LLM 直接进行
这个主要解决第二点问题。
需要让用户或后端进行二次校验,LLM 只是 建议 而非直接操作,最后执行的仍然是用户和后端。
后端追踪
- 日志,建议记录:建议记录:
- 用户输入。
- 命中的工具名。
- 模型生成的参数。
- 服务端校验结果。
- 真实执行的业务请求。
- 工具返回结果。
- 最终回复。
- traceId、userId、tenantId、schemaVersion、model。
做幂等,整个工具意图调用链条中,任何一环都有可能重试,既然重试就必须考虑幂等性,针对任何写操作,必须严格做幂等
超时重试
