【LLM】如何让大模型格式化输出消息?

0 阅读11分钟

如何格式化输出

help.aliyun.com/zh/model-st… platform.openai.com/docs/guides…

如何让大模型格式化输出消息?从简单到复杂,主要有以下几种层级的方法:

1. 纯提示词引导 (Prompt Engineering)

最原始的方法,通过 System Prompt 强制要求。

做法: "请仅返回 JSON 格式,不要包含 Markdown 标记,格式如下:..." 缺点: 不稳定。模型可能会喋喋不休("好的,这是您的 JSON..."),或者 JSON 缺少闭合括号,导致解析失败。

2. 约束模式 (JSON Mode)

OpenAI 等厂商提供的 API 参数(如 response_format={"type": "json_object"})。

做法: 在 API 调用时开启 JSON 模式。 缺点: 仅保证返回的是 JSON,但不保证字段正确。比如你想要 {"age": int},它可能返回 {"age": "unknown"},或者字段名拼写错误。

3. 结构化输出 (Structured Outputs / Pydantic)

主流做法

做法: 直接定义一个 Pydantic 类,要求 LLM 输出该类的实例。OpenAI 的 SDK 和第三方库(如 Instructor)均支持此方式。

Prompt与Pydantic 利弊:

Prompt Engineering

需要编写复杂的 Regex 或 try-catch 逻辑来解析字符串。需要在 Prompt 里用自然语言描述数据结构("输出一个包含 name 和 list 的 json...")在代码里通常还需要再写一遍这个结构来处理数据。一旦需求变更,必须同时修改 Prompt 和代码,极易导致不同步。==》 难维护

Pydantic的优势

  • 自动校验: Pydantic 会自动尝试转换类型(Coercion)。如果模型输出字符串 "100",Pydantic 会自动转为 int(100)

  • 自动重试 (Retry Loop): 结合 Instructor 等库,如果校验失败(例如 email 字段不符合邮箱格式),程序可以将 Pydantic 抛出的 ValidationError 自动回传给 LLM。LLM 会看到错误信息并自我修正,重新输出。

  • Pydantic 类的 Schema(字段名、类型、Field 里的描述)会被自动转换为 JSON Schema 传给 LLM。同时可以处理复杂嵌套结构(利用 Python 的类组合特性)

  • 只需要维护 Python 类定义,Prompt 和 业务逻辑始终保持一致

  • 对开发者友好,Pydantic返回的是python对象,可以直接调用 response.user_name,配合 Type Hinting,效率++

一般的,schema通常用于定义数据的形状、类型和约束。

Pydantic是将 Python 类 (class User(BaseModel)) 自动转译为 LLM 能看懂的 JSON Schema;然后将LLM返回的JSON数据实例化为Py对象,并进行类型检查

Structured outputs:将生成空间的“概率分布”强行收敛到 Schema 上。通常不允许模型拒绝或输出无关闲聊

Function Calling (机制):目的是为了执行某个动作(查天气、发邮件)。模型拥有选择权(决定调用还是不调用)

直观理解:

  • Schema: 菜单。它规定了有什么菜,价格必须是数字,辣度必须是枚举值。

  • Pydantic: 排版印刷机 & 验菜员

    • 它把厨师(开发者)脑子里的菜品定义打印成标准的菜单(JSON Schema)给顾客(LLM)看。
    • 当顾客下完单,它负责检查这道菜是不是真的在菜单上,价格对不对。
  • Function Calling: 服务员的记录本

    • 顾客(LLM)看了一会儿菜单,决定叫服务员(触发 Function Call),并在本子上写下:“我要点宫保鸡丁”。这是一种交互机制
  • Structured Outputs: 自助点餐机

    • 机器的界面被锁死了(Constrained)。顾客(LLM)只能点击屏幕上有的选项,无法手写任何“只有菜单上没有的东西”。它保证了 100% 的格式合规。

相关代码:

from pydantic import BaseModel, Field
import instructor
from openai import OpenAI

# 1. 定义结构(Source of Truth)
class UserInfo(BaseModel):
    name: str = Field(..., description="用户的全名")
    age: int = Field(..., description="年龄,必须是正整数")

# 2. 注入客户端
client = instructor.patch(OpenAI())

# 3. 直接获取对象
user: UserInfo = client.chat.completions.create(
    model="gpt-4o",
    response_model=UserInfo, # 指定 Pydantic 类
    messages=[
        {"role": "user", "content": "我是张三,今年25岁。"}
    ]
)

# 4. 类型安全的使用
print(user.name) # IDE 自动补全
print(user.age + 1) # 确信这是 int,直接运算

关键区别对照表

维度Function CallingStructured OutputsPydantic
层级模型能力层 (Model Capability)推理引擎层 (Inference Constraint)应用代码层 (SDK/Library)
侧重点Action (动作) :侧重于“我要用工具”Data (数据) :侧重于“我要按格式回答”Validation (校验) :侧重于“定义与转换”
模型自由度:模型可以决定调不调,甚至一次调多个:模型被强制按 Schema 输出N/A (它是给开发者用的)
底层技术微调 (Fine-tuning) 让模型学会输出 JSON约束采样 (Constrained Decoding) + JSON ModePython 类型提示 (Type Hints) + 元编程
典型输出{"tool_calls": [...]}纯 JSON 对象Python 对象实例

Why?

为什么通过指定的输出就可以得到JSON形式的数据呢?

openai.com/index/intro… ## Constrained decoding

首先明确一点:response_format={"type": "json_object"} (JSON 模式)和 Structured Outputs(结构化输出,通常对应 json_schema 或 Pydantic 定义)在底层的实现机制上是有区别的。

核心机制:约束采样 (Constrained Sampling)

大模型本质上是一个“概率预测机”,它在生成下一个 Token 时,会计算词表中所有 Token 的概率分布。 如果不加干预,模型可能会根据概率生成任何内容(包括闲聊)。 当你指定格式时,OpenAI 在后端推理引擎(Inference Engine)中引入了一个**“动态掩码(Logit Masking)”**机制。

1. JSON Object Mode ({"type": "json_object"}) 的实现

当你开启这个模式时,推理引擎会加载一个通用的 JSON 语法解析器

  • 预测前: 模型准备生成下一个 Token。

  • 检查: 解析器判断当前生成的字符串是否符合 JSON 语法。

    • 如果当前是 {,下一个 Token 必须是 " (key 的开始) 或 } (空对象)。
    • 如果当前是 "age":,下一个 Token 可以是数字、字符串、布尔值等。
  • 干预(Masking): 推理引擎会将所有导致 JSON 语法错误的 Token 的概率强制设为 -\infty(即 0%)。

  • 结果: 模型被迫只能选择符合 JSON 语法的 Token。

注意: JSON Object 模式只保证语法正确(括号匹配、逗号正确),但不保证内容正确(它不知道你需要 age 字段,它只知道你需要一个 JSON)。这也是为什么文档强调在使用此模式时,必须在 Prompt 中包含“请输出 JSON”字样,否则模型可能会因为不知道该写什么内容而一直输出空白字符,直到超时。

2. Structured Outputs / Pydantic (json_schema) 的实现

这是更高阶的实现(即 OpenAI 2024年8月发布的 Structured Outputs 功能)。当你传入 response_model=UserInfo 时,Pydantic 会先将其转为 JSON Schema

  • 预处理: OpenAI 后端会将你提供的 JSON Schema 转换成一个 上下文无关文法 (Context-Free Grammar, CFG)

  • 执行时的强约束:

    • 在采样过程中,每处理完一个标记后,推理引擎会根据之前生成的标记以及语法规则中表明哪些标记为后续有效标记的信息,来确定接下来有哪些标记可以合法的产生。接着利用这一标记列表来屏蔽后续的采样步骤从而有效降低了产生无效标记的概率。
    • 假设 Schema 定义 age 必须是 integer。当模型生成到 "age": 后面时,推理引擎会根据 Schema 锁定:所有非数字的 Token(如 "twenty" 或 "unknown")概率被屏蔽。模型只能在数字相关的 Token 中进行采样。
  • 优势: 这保证了生成的 JSON 100% 符合你定义的字段和类型,从物理上杜绝了幻觉字段

辅助机制:微调 (Fine-tuning / SFT)

除了推理时的强制约束,模型本身也经过了特定的训练。

  1. 代码与数据训练: GPT-4 等模型在预训练阶段“看”过海量的 JSON 数据和代码,它理解 JSON 的结构。
  2. 指令微调 (Instruction Tuning): OpenAI 对模型进行了特定的 SFT(监督微调)。当模型看到 System Prompt 中包含特定的控制符或 API 参数指示时,它倾向于进入“数据生成模式”而不是“聊天模式”。

为什么 response_model=UserInfo 能直接得到对象?

1. 客户端 (Python/Pydantic/Instructor)

当你在代码中写:

class UserInfo(BaseModel):
    name: str
    age: int

response = client.chat.completions.create(
    model="gpt-4o",
    response_model=UserInfo, ...
)

SDK做了以下工作

  1. Schema 生成: 利用 Pydantic 的 model_json_schema() 方法,将 UserInfo 类转换成标准的 JSON Schema 描述(包含字段名、类型、描述)。
  2. 注入 API: 将这个 Schema 放入 API 请求的 tools 参数(Function Calling 方式)或 response_format 参数(Structured Outputs 方式)中发送给 OpenAI。

2. 服务端 (OpenAI)

OpenAI 接收到 Schema 后,利用上述的 “约束采样” 技术,强迫模型生成符合 Schema 的 JSON 字符串。

3. 回到客户端

SDK 接收到 JSON 字符串(例如 {"name": "Alice", "age": 30}),然后调用 UserInfo.model_validate_json(...) 将其转换回 Python 对象。


那很火的Function Calling 又是什么?

Function Calling

Function Calling 的本质不是让模型去运行代码,而是让模型学会生成调用代码所需的参数,并暂停思考,等待外界把结果喂回来。

Function Calling 的执行并非一次性的,而是一个ReAct(Reason-Act-Observe) 闭环。

我们可以将其拆解为四个物理步骤:

  • Step 1: 认知与决策 (Reasoning)

    • 输入: 用户的 Prompt + 所有的工具定义(Schema)。
    • 模型动作: 模型通过注意力机制扫描 Schema,判断:“仅凭我的内部知识(权重)能否回答?”
    • 决策: 如果不能,它不再生成普通文本,而是生成一个特殊的结构化标记(如 <tool_call>)和符合 Schema 的 JSON 参数(如 {"location": "Beijing"})。
    • 状态: 挂起 (Halt) 。模型此时停止生成,等待外界输入。
  • Step 2: 外部执行 (Execution)

    • 关键点: 模型不执行函数!
    • 动作: 你的 Python/Java 后端代码(Client)捕获到模型的调用请求,解析 JSON,然后在真实的物理世界中运行函数(例如调用高德地图 API)。
  • Step 3: 观测注入 (Observation)

    • 动作: 后端拿到 API 的返回值(如 "25°C, Rainy")。
    • 注入: 后端将这个结果封装成一个特殊的 ToolMessage,追加到对话历史中,再次发送给模型。
    • 此时的 Context: [用户提问, 模型调用请求, 工具返回结果]
  • Step 4: 最终合成 (Synthesis)

    • 动作: 模型看到上下文里有了“工具返回结果”,它认为信息充足了,于是根据结果生成最终的自然语言回答给用户。

MCP

MCP 的本质是将“上下文(Context)”标准化。 就像 USB 协议统一了鼠标、键盘、打印机与电脑的连接一样,MCP 试图统一数据源与 AI 模型的连接。它定义了一种标准,让任何数据源都能以通用的格式暴露给任何 AI 模型。

背景:在 MCP 出现之前,如果你想让你的 AI Agent 连接 GitHub、Slack 和 Google Drive,你需要写三套完全不同的 Connector 代码。

  • 每个数据源 API 不同。
  • 每个 AI 客户端(ChatGPT, Claude, Cursor)集成工具的方式也略有不同。
  • 这是一个 N×MN \times M 的复杂度问题。

MCP 采用 客户端-服务器 (Client-Host-Server) 架构:

  • MCP Server (数据源端): 比如一个连接本地 SQLite 的服务。它不关心谁来调用它,它只按 MCP 标准暴露三样东西:

    1. Resources (资源): 类似于文件或数据块(如数据库里的表结构、文件内容)。供模型读取
    2. Prompts (提示词): 预定义的指令模板(如“帮我分析这个错误日志”)。供模型复用
    3. Tools (工具): 可执行的函数(如“执行 SQL 查询”)。供模型调用
  • MCP Client (AI 端): 如 Claude Desktop 或 Cursor。它们内置了 MCP 解析器,只要连上 Server,就能自动“看见”并使用上面的资源和工具,无需额外编写胶水代码

Function Calling 与 MCP 的区别与联系

这是最容易混淆的部分,我们通过第一性原理来区分:

维度Function CallingMCP (Model Context Protocol)
本质交互机制 (Mechanism)连接标准 (Standard)
层级底层原子能力上层架构协议
比喻打电话的能力 (我能拨号、说话、听声音)电话网络 & 黄页 (统一的插孔、号码簿、接通协议)
解决的问题模型如何告诉程序去执行代码?模型如何零配置地连接各种异构数据源?
关系MCP 的 "Tools" 功能底层依赖于 Function CallingMCP 是 Function Calling 的一种高级封装和标准化实现

深度分析:

  1. Function Calling 是“动词”: 它是 LLM 具备的一项基本技能。没有 Function Calling,MCP 里的 Tools 无法被触发。

  2. MCP 是“生态”:

    • 没有 MCP 时: 你为了让 Agent 查天气,得手写一个 get_weather 函数并在 Prompt 里定义 Schema。
    • 有 MCP 时: 你只需启动一个 weather-mcp-server。你的 Agent(只要支持 MCP)自动就会获得查天气的能力,甚至还能获得查看天气历史数据(Resources)和天气播报模板(Prompts)的能力。