从 SSE 流式到结构化输出:LangChain 三大 OutputParser 与 ToolCall 方案全实战

前言

做 LLM 应用开发,两个核心问题绕不开:一是流式输出的打字机体验 ,总不能让用户盯着空白屏等全部生成完才看到内容;二是结构化数据输出,程序要做后续业务逻辑,必须拿到标准 JSON,而不是带 Markdown 包裹的自由文本。

很多新手踩过无数坑:prompt 里明明写了 "返回 JSON",结果大模型总把 JSON 包在代码块里,直接 JSON.parse() 直接报错。今天我们从底层原理到实战代码,把这件事彻底讲透,所有方案均附完整可运行代码。

一、流式输出的底层:SSE 服务器推送事件

很多人说的 "流式输出""打字机效果",底层本质就是 SSE(Server-Sent Events) 。

1.1 传统 HTTP vs SSE

  • 传统 HTTP:一次请求 → 一次响应 → 连接断开。要拿新数据必须重新发请求。
  • SSE :浏览器发起一次请求,建立长连接,服务器可以持续、多次地向客户端推送数据块(chunk),连接不主动断开。

就像水管的比喻:一头连 LLM 服务器,一头连客户端,token 源源不断流过来,客户端边收边展示,本地做 buffer 拼接。

SSE 响应的三个核心标识:

css 复制代码
Content-Type: text/event-stream;
Cache-Control: no-cache;
Connection: keep-alive;
  • text/event-stream:告诉浏览器这是 SSE 事件流,按流式协议解析
  • no-cache:不缓存数据,保证实时性
  • keep-alive:保持 TCP 长连接不中断

1.2 原生 SSE 最小实现(Node.js + 浏览器)

服务端(Node.js 原生 HTTP 模块,模拟逐字推送):

javascript 复制代码
const http = require('http');

const server = http.createServer((req, res) => {
    // 跨域处理
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

    if (req.url === '/') {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('hello world');
    } 
    // SSE 流式接口
    else if (req.url === '/stream') {
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'cache-control': 'no-cache',
            'connection': 'keep-alive'
        });

        // 模拟逐字推送数据
        const words = ['你', '好', '欢', '迎'];
        let index = 0;
        const timer = setInterval(() => {
            if (index >= words.length) {
                clearInterval(timer);
                res.end();
                return;
            }
            // SSE 固定数据格式:data: 内容\n\n
            res.write(`data: ${words[index]}\n\n`);
            index++;
        }, 1000);
    }
});

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

前端(浏览器原生 EventSource API 接收 SSE):

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE 流式演示</title>
</head>
<body>
    <h1>SSE DEMO</h1>
    <div id="result"></div>

    <script>
        const resultEle = document.getElementById('result');
        // 浏览器原生API,专门建立SSE长连接
        const eventSource = new EventSource("http://localhost:3000/stream");

        // 每次有新的chunk到达,触发onmessage事件
        eventSource.onmessage = (e) => {
            console.log('收到数据:', e.data);
            resultEle.innerHTML += e.data;
        };
    </script>
</body>
</html>

关键点:SSE 是服务器单向推送,只能服务端给客户端发数据。LLM 流式输出刚好是单向场景,所以 SSE 是行业最主流的方案。

二、LangChain 中的流式输出

在 LangChain 里,不用自己手写 SSE 拼接和解析,调用模型的 .stream() 方法就能拿到流式异步迭代器,用 for await...of 逐块读取。

javascript 复制代码
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

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

const prompt = '你是谁';
console.log('普通流式输出演示:(无结构化)\n');

try {
    // invoke:同步等待全部结果返回
    // stream:返回异步迭代器,逐块接收token
    const stream = await model.stream(prompt);
    
    let chunkCount = 0;
    let fullContent = '';

    // 遍历流式数据块
    for await (const chunk of stream) {
        chunkCount++;
        const content = chunk.content;
        fullContent += content;
        // 逐字打印到控制台,模拟打字机效果
        process.stdout.write(content);
    }

    console.log('\n\n数据块总数:', chunkCount);
    console.log('完整拼接内容:', fullContent);
} catch (error) {
    console.error('模型调用失败:', error);
}

本质上,LangChain 底层也是通过 HTTP 分块编码 / SSE 接收模型的流式响应,封装成了易用的迭代器接口,不用我们手动处理协议细节。

三、结构化输出的核心痛点

业务开发中我们经常需要大模型返回 JSON,用来做后续业务逻辑。但直接在 prompt 里写 "返回 JSON",大概率会踩三个坑:

  1. 大模型习惯输出 Markdown 格式,会把 JSON 包在 json ... 代码块里
  2. 经常附带解释文字,比如 "好的,这是为你生成的 JSON:"
  3. 字段名、数据类型不固定,可能少字段、类型错误(比如数字写成字符串)

新手最常见的解决办法 ------ 手写正则剥离:

javascript 复制代码
// 从模型返回文本中提取 JSON 字符串
function extractJson(text) {
    // 去掉 markdown 代码块标记(```json ... ```)
    const cleaned = text.replace(/```json|```/g, '').trim()
    // 截取第一个 { 到最后一个 } 之间的内容,兼容前后附带说明文字的情况
    const start = cleaned.indexOf('{')
    const end = cleaned.lastIndexOf('}')
    if (start === -1 || end === -1) {
        throw new Error('未在模型输出中找到 JSON 内容')
    }
    return cleaned.slice(start, end + 1)
}

这种写法能解决简单问题,但维护成本高,边界情况多(比如嵌套 JSON、代码里有反引号)。LangChain 的 OutputParser 系列就是专门解决这个问题的:自动剥离 Markdown 包裹、解析 JSON、甚至校验字段类型。

四、方案一:JsonOutputParser 基础 JSON 解析

核心作用:只告诉大模型 "输出 JSON 格式,可以用 ```json 代码块包裹",自动剥离标记并解析成 JS 对象。

  • 优点:最简单,最轻量,兼容所有模型
  • 缺点:不会自动定义字段,字段要求必须你自己写在 prompt 里

完整可运行代码

javascript 复制代码
import 'dotenv/config'
import { ChatOpenAI } from '@langchain/openai'
import { JsonOutputParser } from '@langchain/core/output_parsers'

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

// 1. 创建解析器实例
const parser = new JsonOutputParser();

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

try {
    console.log("正在调用大模型...\n");
    const response = await model.invoke(prompt);
    console.log('模型原始返回:', response.content);

    // 3. 解析文本,自动剥离markdown、转成JS对象
    const result = await parser.parse(response.content)
    console.log('\n✅ 解析后的JSON对象:');
    console.log(result);
    
} catch (error) {
    console.error('解析失败:', error.message);
}

关键注意点

  1. parser.getFormatInstructions() 只是生成一段 "输出 JSON,不要多余文字" 的提示词,不会帮你定义字段,字段名和要求必须自己写。
  2. parser.parse() 是异步函数,必须加 await。
  3. 它替代的就是上面手写的 extractJson + JSON.parse 两步,内置了更完善的边界处理。

五、方案二:StructuredOutputParser 自动字段生成

核心作用:你预先定义字段名和描述,它自动生成完整的字段规则提示词,不用你手写一大段字段要求。 根据定义方式不同,分为两个版本。

5.1 快捷版:fromNamesAndDescriptions

直接传键值对对象,适合扁平单层 JSON 结构,代码最简洁。

完整可运行代码

javascript 复制代码
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } 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,
  }
});

// 1. 直接传入 {字段名: 字段描述},快速创建解析器
const parser = StructuredOutputParser.fromNamesAndDescriptions({
  name: "姓名",
  birth_year: "出生年份",
  nationality: "国籍",
  major_achievements: "主要成就, 用逗号分隔的字符串",
  famous_theory: "著名理论"
});

// 2. 自动生成包含全部字段说明的格式指令
const question = `
请介绍一下爱因斯坦的信息。
${parser.getFormatInstructions()}
`;

try {
  console.log('正在调用大模型 (使用 StructuredOutputParser) \n');
  const response = await model.invoke(question);
  console.log('模型原始返回:', response.content);

  // 3. 解析得到对象
  const result = await parser.parse(response.content);
  console.log(`\n姓名:${result.name}`);
  console.log(`国籍:${result.nationality}`);
} catch (error) {
  console.error('解析错误:', error);
}

和 JsonOutputParser 的核心区别:

  • JsonOutputParser:只管输出 JSON 格式,字段描述全靠你手写
  • StructuredOutputParser:你定义字段,它自动生成完整的字段说明提示词

5.2 强类型版:fromZodSchema 嵌套结构

直接传入 Zod Schema,支持嵌套对象、数组、可选字段、类型约束,适合复杂结构化场景。

完整可运行代码

css 复制代码
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
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,
  }
});

// 1. 用Zod定义完整数据结构,支持嵌套、可选、类型约束
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字以内'),
});

// 2. 从Zod Schema创建解析器
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

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

try {
  console.log('正在调用大模型 (使用 ZodSchema) \n');
  const response = await model.invoke(question);
  console.log('模型原始返回:', response.content);

  const result = await parser.parse(response.content);
  console.log(`\n姓名:${result.name}`);
  console.log(`国籍:${result.nationality}`);
  console.log(`获奖数量:${result.awards.length}`);
} catch (error) {
  console.error('解析错误:', error);
}

Zod 常用语法速记

  • z.string() / z.number():基础类型
  • z.array(z.string()):字符串数组
  • z.object({...}):嵌套对象
  • .optional():可选字段,可以不返回
  • .describe('xxx'):字段描述,会生成到提示词里给大模型看

六、进阶方案:用 ToolCall 实现强结构化输出

还有一种更巧妙的生产级思路:借用大模型的工具调用(Tool Call)能力来做结构化输出。

6.1 原理

大模型的工具调用能力,天生要求调用参数必须严格符合定义好的 Schema。我们可以 "伪造" 一个工具,不真的执行它的逻辑,只是让模型把结果塞进工具参数里,直接从 tool_calls 里拿到结构化数据。

完整可运行代码

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

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

// 定义数据结构Schema
const scientistSchema = z.object({
    name: z.string().describe('科学家的姓名'),
    birth_year: z.number().describe('出生年份'),
    nationality: z.string().describe('国籍'),
    fields: z.array(z.string()).describe('研究领域列表'),
});

// 绑定工具到模型
const modelWithTool = model.bindTools([
    {
        name: "extract_scientist_info",
        description: "提取和结构化科学家的详细信息",
        schema: scientistSchema,
    }
], {
    // 强制模型必须调用这个工具,避免直接输出自然语言
    tool_choice: { type: "function", function: { name: "extract_scientist_info" } }
});

// 调用模型
const response = await modelWithTool.invoke("介绍一下爱因斯坦");

// 直接拿到结构化参数,不需要再parse
const data = response.tool_calls[0].args;
console.log('✅ 工具调用返回的结构化数据:', data);

核心优势

模型原生层面遵守 Schema,格式稳定性远高于 "prompt 引导 + 文本解析" 的 OutputParser 方案,几乎不会出现格式错误。

七、方案对比与选型建议

表格

方案 格式稳定性 依赖模型能力 适用场景
手写正则 + JSON.parse 差 无要求 临时调试、极简单场景
JsonOutputParser 一般 无要求,任何模型都能用 简单 JSON 输出,模型不支持工具调用
StructuredOutputParserfromNamesAndDescriptions 较好 无要求 扁平结构,字段多,不想手写提示词
StructuredOutputParserfromZodSchema 好 无要求 复杂嵌套结构、需要类型约束
ToolCall 强制调用 最好 必须支持工具调用 生产环境、强格式要求、高稳定性需求

八、总结

  1. 流式输出底层是 SSE:单向长连接,逐 chunk 推送数据,实现打字机体验。
  2. OutputParser 解决的核心问题:自动剥离 Markdown 代码块,解析 JSON,不用手写正则。
  3. 选型思路:简单场景用 JsonOutputParser;字段多用 StructuredOutputParser;复杂嵌套用 ZodSchema;生产环境强稳定优先用 ToolCall 方案。
  4. 两个必记关键点 :getFormatInstructions() 是给大模型看的提示词;parse() 是给代码用的解析函数,必须加 await
相关推荐
YIAN1 小时前
从 SSE 流式原理到 LangChain 结构化输出:打字机效果与 JSON 解析全方案实战
前端·langchain
Moment2 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试
gnip2 小时前
Flutter 原生插件开发实战指南
前端·flutter
晓得迷路了2 小时前
栗子前端技术周刊第 148 期 - Turborepo 2.11、Chrome 154 iframe、Node.js 26...
前端·javascript·css
IT_陈寒2 小时前
Vite热更新失效?你可能漏了这个配置项
前端·人工智能·后端
10年前端老司机3 小时前
Next.js+LangGraph.js+ 简历工具AI Agent完整落地
前端·langchain·agent
IT_陈寒8 小时前
Java中equals方法比了个寂寞?原来这才是正确的重写姿势
前端·人工智能·后端
默_笙8 小时前
🚓 分诊台与拆题术:让 RAG 学会判断和规划
前端·javascript
CopyCode8 小时前
用 AI 迁项目有多爽?我把 Webpack 迁 Vite 的全过程记下来了
前端·架构