一文搞懂结构化大模型输出:从 SSE 到 Tool Calling 的四种姿势

LLM 返回的是自由文本,但你的业务需要 JSON。本文从 SSE 流式传输讲起,逐层拆解四种结构化输出方案,附完整代码和面试高频问。


前言

用 LLM 做业务时,你一定遇到过这个问题:大模型返回的是一段话,但你要的是一个 JSON 对象。

比如让它"介绍一下爱因斯坦",它返回的是:

erlang 复制代码
莫扎特是奥地利作曲家,出生于1756年......

但你的代码需要的是:

json 复制代码
{"name": "爱因斯坦", "birth_year": 1879, "nationality": "德国"}

怎么让 LLM 按格式输出?输出了又怎么解析?本文从最原始的方案讲到 LangChain 封装的终极方案,四种姿势,层层递进

你将会收获:

  • 理解 SSE 协议与流式传输的底层原理
  • 掌握正则提取、JsonOutputParser、StructuredOutputParser、Tool Calling 四种方案
  • 搞懂 Zod schema 与结构化输出的关系
  • 面试中遇到"LLM 结构化输出"不再慌

技术栈: Node.js + LangChain + OpenAI API + Zod


一、SSE:大模型"打字机效果"的底层协议

1.1 为什么需要 SSE?

你用 ChatGPT 时,回答是一个字一个字蹦出来的,不是等 5 秒后一次性全出来。这背后就是 SSE(Server-Sent Events)

复制代码
传统 HTTP:  请求 → 等5秒 → 一次性返回 → 断开
SSE:        请求 → 保持连接 → 一点一点推送 → 结束后断开

类比一下:传统 HTTP 是你点了一桌菜,厨师全部做完才端上来;SSE 是厨师每做完一道菜,服务员就端一道上来,你边吃边等

1.2 SSE 响应头

js 复制代码
res.writeHead(200, {
  'Content-Type': 'text/event-stream',   // 告诉浏览器:这是事件流
  'Cache-Control': 'no-cache',           // 禁止缓存
  'Connection': 'keep-alive',            // 保持长连接
});

对比普通 HTTP:

普通 HTTP SSE
Content-Type text/html text/event-stream
连接 发完就断 保持不断
数据 一次性 分批推送
方向 请求-响应 服务器单向推送

1.3 SSE 消息格式

kotlin 复制代码
data: 你好\n\n
data: 世界\n\n
data: [DONE]\n\n
  • data: 是固定前缀(注意有空格)
  • \n\n 是消息分隔符(双换行)
  • 浏览器的 EventSource 收到后会自动剥掉 data:,只把内容放到 e.data

1.4 手写 SSE 服务

js 复制代码
const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    // 返回 HTML 页面
    const filePath = path.join(__dirname, 'index.html');
    fs.readFile(filePath, (err, data) => {
      if (err) {
        res.writeHead(500, { 'Content-Type': 'text/plain' });
        res.end('Internal Server Error');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(data);
    });
  } else if (req.url === '/stream') {
    // SSE 流式推送
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });
    let words = ['你', '好', ', ', '欢', '迎', '了', '解', 'sse'];
    let index = 0;
    const timer = setInterval(() => {
      if (index >= words.length) {
        clearInterval(timer);
        res.end(); // 关闭连接
        return;
      }
      res.write(`data: ${words[index]}\n\n`); // SSE 格式发送
      index++;
    }, 1000);
  }
});

server.listen(3000, () => {
  console.log('server is running on port 3000');
});

1.5 前端接收

html 复制代码
<div id="result"></div>
<script>
  const resultEle = document.getElementById('result');
  const eventSource = new EventSource('http://localhost:3000/stream');
  eventSource.onmessage = (e) => {
    console.log(e.data);          // "你"、"好"、", " ...
    resultEle.innerText += e.data; // 逐字拼接
  };
</script>

效果:页面上一个字一个字蹦出 "你好, 欢迎了解sse"。

💡 一句话记住 SSE :HTTP 长连接 + data: 前缀 + EventSource 自动解析 = 服务器单向推送。

1.6 SSE 不只用于 LLM

场景 说明
LLM 聊天 ChatGPT、DeepSeek 的打字机效果
股票行情 实时推送价格变动
消息通知 站内信、系统告警
日志流 实时查看构建/部署日志

二、outputparser:让 LLM 返回 JSON 的第一个思路

2.1 问题:LLM 返回的是自由文本

让 LLM "以 JSON 格式返回",它确实会返回 JSON,但往往被 markdown 代码块包裹:

json 复制代码
```json
{"name": "爱因斯坦", "birth_year": 1879}
go 复制代码
你直接 `JSON.parse()` 会报错,因为字符串是:

```js
'```json\n{"name": "爱因斯坦", "birth_year": 1879}\n```'

前面多了 ```````json````,后面多了 ```````````,不是合法 JSON。

2.2 原始方案:正则提取

js 复制代码
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const result = JSON.parse(jsonStr);

正则拆解:

片段 含义
``````````` 匹配三个反引号
json 匹配字面量 json
\s* 匹配空白(含换行)
([\s\S]*?) 捕获组:任意字符,非贪婪
``````````` 匹配结尾的三个反引号

问题 :每次都要手写正则,太麻烦。而且 LLM 有时不写 ```````json````,只返回裸 JSON,正则就匹配不到了。

2.3 Prompt Output 技巧

更靠谱的做法是从源头约束:在 prompt 里明确告诉 LLM 要什么格式。

js 复制代码
const prompt = `
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
`;

三个技巧

  1. 明确说"以 JSON 格式返回"
  2. 列出每个字段的 key 和含义
  3. temperature: 0 减少随机性

⚠️ 面试考点 :LLM 结构化输出的核心思路是prompt 约束 + 事后解析,两手都得有。prompt 约束是源头治理,解析是兜底。


三、JsonOutputParser:LangChain 的第一个封装

3.1 是什么

JsonOutputParser 是 LangChain 提供的 JSON 解析器,把"正则提取 + JSON.parse"封装好了。

3.2 怎么用

js 复制代码
import { ChatOpenAI } from '@langchain/openai';
import { JsonOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

const parser = new JsonOutputParser();

const prompt = `
请介绍一下爱因斯坦的信息。请以 JSON 格式返回,
包含以下字段:name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievements(主要成就, 数组)、
famous_theory(著名理论)
${parser.getFormatInstructions()}
`;

const response = await model.invoke(prompt);
const result = await parser.parse(response.content);
console.log(result.name); // "爱因斯坦"

3.3 两个关键方法

js 复制代码
// ① 在 prompt 末尾追加格式说明
${parser.getFormatInstructions()}
// 输出类似:"Return a JSON object with the following schema: ..."

// ② 解析 LLM 返回的文本
const result = await parser.parse(response.content);
// 内部:正则提取 markdown → JSON.parse → 返回 JS 对象

3.4 本质

scss 复制代码
getFormatInstructions()  → 在 prompt 里追加格式约束(源头)
parser.parse()           → 正则提取 markdown + JSON.parse(兜底)

💡 一句话记住:JsonOutputParser = prompt 约束 + 正则提取 + JSON.parse,三合一。


四、StructuredOutputParser:约束字段结构

4.1 JsonOutputParser 的问题

JsonOutputParser 只管"返回 JSON",不管"JSON 里面有哪些字段、什么类型"。LLM 可能返回:

json 复制代码
{"name": "爱因斯坦", "year": 1879}  // key 和你要求的不一样

4.2 方式一:fromNamesAndDescriptions

js 复制代码
import { StructuredOutputParser } from '@langchain/core/output_parsers';

const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: '姓名',
  birth_year: '出生年份',
  nationality: '国籍',
  major_achievements: '主要成就, 用逗号分隔的字符串',
  famous_theory: '著名理论',
});

const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);

比 JsonOutputParser 多了什么? 每个字段都指定了 key 和描述,LLM 会严格按这些 key 返回。

4.3 方式二:fromZodSchema(更强约束)

fromNamesAndDescriptions 只能定义扁平的 key-value,不支持类型、嵌套、可选字段。这时候就需要 Zod

js 复制代码
import { StructuredOutputParser } from '@langchain/core/output_parsers';
import { z } from 'zod';

// 用 Zod 定义 schema
const scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  death_year: z.number().optional().describe('死亡年份, 如果还在世则不填'),
  nationality: z.string().describe('国籍'),
  fields: z.array(z.string()).describe('研究领域列表'),
  awards: z.array(
    z.object({
      name: z.string().describe('奖项名称'),
      year: z.number().describe('获奖年份'),
      reason: z.string().describe('获奖原因'),
    })
  ).describe('获得的重要奖项列表'),
  major_achievements: z.array(z.string()).describe('主要成就列表'),
  famous_theory: z.array(
    z.object({
      name: z.string().describe('理论名称'),
      year: z.number().describe('提出年份'),
      description: z.string().describe('理论简要描述'),
    })
  ).describe('著名理论列表'),
  biography: z.string().describe('简短传记,100字以内'),
});

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

const question = `请介绍一下居里夫人的详细信息,
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(`姓名:${result.name}`);
console.log(`国籍:${result.nationality}`);

4.4 两种方式对比

fromNamesAndDescriptions fromZodSchema
定义方式 key: "描述" z.object({...})
类型约束 ❌ 只有描述 z.string()z.number()
嵌套结构 ❌ 扁平 ✅ 支持嵌套对象、数组
可选字段 ❌ 不支持 .optional()
校验强度

4.5 Zod 是什么?

Zod 是 TypeScript 的 schema 校验库,用来定义"数据应该长什么样"。

js 复制代码
z.string()           // 字符串
z.number()           // 数字
z.array(z.string())  // 字符串数组
z.object({...})      // 对象
z.number().optional() // 可选数字

三者关系:

arduino 复制代码
Schema(概念)    →  "数据长什么样" 的定义
    ↑
Zod(工具)       →  用代码写 schema 的库
    ↑
StructuredOutputParser(消费者)  →  拿着 schema 去约束 LLM

💡 一句话记住:Zod 是画图纸的笔,StructuredOutputParser 是拿着图纸去验收的工人。


五、Tool Calling:终极方案

5.1 从 tool-call schema 得到的灵感

前面三种方案都是让 LLM 返回文本,再解析文本 。但 LLM 有一个原生能力:Tool Calling(工具调用)

工具调用的参数本身就是结构化的 JSON。所以我们可以:

复制代码
不把 schema 用来解析文本输出
而是把 schema 定义成工具参数,让 LLM 通过工具调用返回结构化数据

5.2 实现

js 复制代码
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

// 同样的 Zod schema
const scientistSchema = z.object({
  name: z.string().describe('科学家的姓名'),
  birth_year: z.number().describe('出生年份'),
  nationality: z.string().describe('国籍'),
  fields: z.array(z.string()).describe('研究领域列表'),
});

// 把 schema 绑定为工具参数
const modelWithTool = model.bindTools([
  {
    name: 'extract_scientist_info',
    description: '提取和结构化科学家的详细信息',
    schema: scientistSchema,
  },
]);

// 注意:这个工具不是真的要执行,只是借用 tool calling 的结构化能力
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// 直接拿到结构化数据,不需要 JSON.parse

5.3 为什么比 OutputParser 好?

javascript 复制代码
OutputParser 方案:
  LLM 返回文本 → 正则提取 → JSON.parse → 可能失败
  (文本 → 解析,有信息损耗)

Tool Calling 方案:
  LLM 直接返回工具调用参数 → 天然就是结构化 JSON
  (模型原生能力,不依赖文本解析)
OutputParser Tool Calling
数据来源 从文本中提取 模型原生输出
解析风险 可能 JSON 不合法 天然结构化
类型校验 依赖 Zod 事后校验 模型参数自带类型
嵌套支持 需要 Zod 定义 原生支持

💡 一句话记住 :Tool Calling 不是为了调用工具,而是借用工具调用的结构化能力拿到靠谱的 JSON。

5.4 那 OutputParser 还有必要存在吗?

有。Tool Calling 要求模型支持 function calling(大多数主流模型都支持),而 OutputParser 是纯文本解析,兼容性更好。而且有些场景你只需要简单的 JSON 解析,杀鸡不用牛刀。

选择建议

javascript 复制代码
简单场景(只要 JSON)        → JsonOutputParser
需要字段约束                → StructuredOutputParser + Zod
需要最强结构化保证          → Tool Calling + Zod

六、四种方案全景对比

方案 复杂度 约束强度 原理
正则提取 最弱 prompt 约束 + 正则 + JSON.parse
JsonOutputParser ⭐⭐ prompt 格式说明 + 正则 + JSON.parse
StructuredOutputParser ⭐⭐⭐ prompt 字段约束 + 正则 + JSON.parse
Tool Calling ⭐⭐⭐⭐ 模型原生结构化输出
复制代码
正则提取 → JsonOutputParser → StructuredOutputParser → Tool Calling
  ↓              ↓                    ↓                    ↓
手动正则     封装正则            加字段约束             模型原生能力
最弱                                                          最强

七、面试高频问

Q1:LLM 返回的 JSON 被 markdown 包裹怎么办?

两种方案:

  1. 正则提取text.match(/```json\s*([\s\S]*?)\s*```/) 取捕获组
  2. LangChain ParserJsonOutputParser.parse() 内部自动处理

本质都是"先清洗再 parse",推荐用 LangChain 封装,省去手写正则的麻烦。
Q2:JsonOutputParser 和 StructuredOutputParser 的区别?

  • JsonOutputParser:只管返回 JSON 格式,不管字段结构
  • StructuredOutputParser:除了 JSON 格式,还约束字段名、类型、描述

升级路径:fromNamesAndDescriptions(简易版)→ fromZodSchema(完整版,支持嵌套、可选、类型校验)。
Q3:什么是 Zod?和 StructuredOutputParser 什么关系?

Zod 是 TypeScript 的 schema 校验库,用来定义数据结构。StructuredOutputParser.fromZodSchema(zodSchema) 把 Zod 定义的 schema 传给解析器,解析器用它来约束 LLM 输出并校验返回结果。

类比:Zod 是"图纸",StructuredOutputParser 是"验收员"。
Q4:Tool Calling 为什么比 OutputParser 更靠谱?

OutputParser 是从 LLM 返回的文本中提取 JSON ,有截断、格式错误等风险。Tool Calling 是 LLM 的原生结构化输出能力,返回的工具调用参数本身就是合法 JSON,不依赖文本解析。

类比:OutputParser 是"从作文里找答案",Tool Calling 是"直接填表格"。
Q5:getFormatInstructions() 做了什么?

在 prompt 末尾追加一段格式说明,告诉 LLM "请按以下 JSON schema 返回"。这是prompt 约束的核心------通过在 prompt 中嵌入格式要求,引导 LLM 按指定结构输出。
Q6:SSE 和 WebSocket 的区别?

  • SSE:单向(服务器→客户端),基于 HTTP,浏览器内置 EventSource 自动重连
  • WebSocket:双向,独立协议(ws://),需要手动处理重连

LLM 流式输出只需要服务器推数据,用 SSE 更简单。


总结

核心概念速查表

概念 一句话
SSE 服务器单向推送,data: 前缀 + \n\n 分隔
EventSource 浏览器内置 SSE 客户端,自动解析、自动重连
JsonOutputParser LangChain 的 JSON 解析器,prompt 约束 + 正则 + parse
StructuredOutputParser 在 JsonOutputParser 基础上加字段约束
Zod TypeScript schema 库,定义数据结构
Tool Calling 模型原生结构化输出,不依赖文本解析
getFormatInstructions() 在 prompt 里追加格式约束说明
parser.parse() 正则提取 markdown + JSON.parse

一句话总结

markdown 复制代码
LLM 结构化输出 = prompt 约束(源头)+ 解析兜底(事后)
                  从正则 → Parser → Zod → Tool Calling,层层升级。

核心代码骨架

js 复制代码
// 终极方案:Tool Calling + Zod
const schema = z.object({
  name: z.string().describe('姓名'),
  age: z.number().describe('年龄'),
});
const modelWithTool = model.bindTools([{ name: 'extract', schema }]);
const response = await modelWithTool.invoke('介绍一下xxx');
const result = response.tool_calls[0].args; // 天然结构化,不用 parse

希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥

相关推荐
星星落进兜里2 小时前
Redis 内存缓存,面试补充
数据库·redis·面试
蒸蒸yyyyzwd2 小时前
cpp 选手秋招学习笔记 day27
服务器·c++·面试·八股
程序员清风3 小时前
聊聊怎么缓解找工作的焦虑感?
java·后端·面试
杰克尼4 小时前
同城拼车项目面试问题_01
spring·面试·职场和发展
YonyouHRSaaS4 小时前
2026年AI招聘系统怎么选?AI面试功能怎么评估?
人工智能·面试·职场和发展·求职招聘·ai面试
写代码像蔡徐抻4 小时前
三年了,AI为何还没有抢走程序员饭碗?
前端·后端·面试
YHL4 小时前
🚀 SDD:告别「氛围编程」,迎接规范驱动开发新时代
面试
天衍四九-5 小时前
Agent Skills从入门到工程化(十六):面试中如何讲清楚 Agent Skills?
大数据·数据库·人工智能·python·chatgpt·面试
秋天的一阵风5 小时前
🤔首屏Banner压到40KB,LCP还是4秒?原来一直搞错了最大渲染元素
前端·人工智能·面试