从 SSE 流式原理到 LangChain 结构化输出:打字机效果与 JSON 解析全方案实战

前言

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

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

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

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

1.1 传统 HTTP vs SSE

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

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

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.parse

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

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

新手最常见的错误写法:

javascript 复制代码
// 理想:直接parse得到对象
const data = JSON.parse(response.content); 
// 现实:直接报错,因为JSON外面包了一层markdown代码块

笨办法是手写正则剥离 ```json 标记,再执行 parse。但每个接口都写一遍,维护成本高,还容易漏掉各种边界情况。

LangChain 的 OutputParser 系列就是专门解决这个问题的:自动剥离 Markdown 包裹、解析 JSON、甚至校验字段类型。

四、LangChain 三类 OutputParser 全对比

4.1 JsonOutputParser:最基础的 JSON 解析

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

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

核心用法:

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

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

// 2. 拿到格式指令,塞进prompt里传给大模型
const prompt = `
介绍爱因斯坦的信息,包含字段:name(姓名) birth_year(出生年份) nationality(国籍)
${parser.getFormatInstructions()}
`;

// 3. 解析模型返回的原始文本
const result = await parser.parse(response.content);
console.log(result); // 直接得到JS对象

注意:getFormatInstructions() 只是生成一段 "输出 JSON,不要多余文字" 的提示词,不会帮你定义字段,字段名和要求必须自己写。

4.2 StructuredOutputParser:自动生成字段说明

核心作用:你预先定义字段名和描述,它自动生成完整的字段规则提示词,不用你手写一大段字段要求。

两种创建方式:

方式 1:fromNamesAndDescriptions(快捷写法)

直接传键值对对象,适合扁平单层 JSON 结构。

php 复制代码
import { StructuredOutputParser } from 'langchain/output_parsers';

const parser = StructuredOutputParser.fromNamesAndDescriptions({
    name: "科学家的姓名",
    birth_year: "出生年份,数字",
    nationality: "国籍",
    fields: "研究领域列表,字符串数组"
});

// 自动生成包含全部字段说明的格式指令
const formatInstructions = parser.getFormatInstructions();

方式 2:fromZodSchema(强类型)

直接传入 Zod Schema,支持嵌套结构、类型约束。

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

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

const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

和 JsonOutputParser 的核心区别:

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

4.3 补充:ZodOutputParser 最强校验

如果需要严格校验字段类型 (比如年份必须是数字、数组不能是字符串),用ZodOutputParser。解析时如果类型不匹配、字段缺失,会直接抛出明确的校验错误,适合生产环境强校验场景。

五、进阶玩法:用 ToolCall 实现强结构化输出

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

5.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);

5.2 方案对比表

表格

方案 格式稳定性 依赖模型能力 适用场景
JsonOutputParser 一般 无要求,任何模型都能用 简单场景,模型不支持工具调用
StructuredOutputParser 较好 无要求 字段较多,不想手写提示词
ToolCall 方案 最好 必须支持工具调用 强约束、复杂 Schema,生产环境优先

ToolCall 的核心优势:模型原生层面遵守 Schema,格式稳定性远高于 "prompt 引导 + 文本解析" 的 OutputParser 方案。

六、总结与选型建议

  1. 只需要流式打字机效果 :用model.stream(),服务端配合 SSE 推送给前端
  2. 简单 JSON 输出,模型不支持工具调用 :用JsonOutputParser
  3. 字段多,想自动生成字段说明 :用StructuredOutputParser.fromNamesAndDescriptions
  4. 需要类型校验、嵌套结构 :用StructuredOutputParser.fromZodSchema 或 ZodOutputParser
  5. 生产环境、强格式要求、模型支持工具调用 :优先用ToolCall 强制调用方案,稳定性最高
相关推荐
Moment2 小时前
如果你在做 RAG,可能会需要 pdf-inspector
前端·后端·面试
gnip2 小时前
Flutter 原生插件开发实战指南
前端·flutter
晓得迷路了3 小时前
栗子前端技术周刊第 148 期 - Turborepo 2.11、Chrome 154 iframe、Node.js 26...
前端·javascript·css
IT_陈寒3 小时前
Vite热更新失效?你可能漏了这个配置项
前端·人工智能·后端
10年前端老司机4 小时前
Next.js+LangGraph.js+ 简历工具AI Agent完整落地
前端·langchain·agent
IT_陈寒8 小时前
Java中equals方法比了个寂寞?原来这才是正确的重写姿势
前端·人工智能·后端
默_笙8 小时前
🚓 分诊台与拆题术:让 RAG 学会判断和规划
前端·javascript
CopyCode8 小时前
用 AI 迁项目有多爽?我把 Webpack 迁 Vite 的全过程记下来了
前端·架构
去伪存真8 小时前
Electron 自动化发布指南:GitHub Actions 跨平台打包全纪录
前端·electron