Skip to content

结构化大模型输出:output parser 还是 tool

我们已经调用大模型完成过很多功能了,但输出一直没做控制,都是自然语言的形式。而很多情况下,我们希望大模型按照我们的格式要求,返回一个 JSON——这就需要用到 output parser 的 API 了。

有同学说,这个不就是在 prompt 里描述下要什么格式,然后按照这种格式解析大模型返回的结果字符串么?没错,就是这种思路,只不过 output parser 对这个思路做了一下封装。

我们直接写代码来试一下:

bash
mkdir output-parser-test
cd output-parser-test
npm init -y
pnpm install @langchain/core @langchain/openai chalk dotenv zod

.env 配置和之前一样(OPENAI_API_KEY / OPENAI_BASE_URL / MODEL_NAME=qwen-plus)。

问题引入:直接要求 JSON 格式 ​

创建 src/normal.mjs:

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

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

// 简单的问题,要求 JSON 格式返回
const question = "请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name(姓名)、birth_year(出生年份)、nationality(国籍)、famous_theory(著名理论)、major_achievements(主要成就)";

try {
  console.log("🤔 正在调用大模型...\n");
  const response = await model.invoke(question);
  console.log("✅ 收到响应:\n");
  console.log(response.content);
  // 解析 JSON
  const jsonResult = JSON.parse(response.content);
  console.log("\n📋 解析后的 JSON 对象:");
  console.log(jsonResult);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

我们让大模型用 JSON 格式返回爱因斯坦的信息,然后把返回的 json 解析成对象。跑一下会发现:返回的内容带了额外的 markdown 语法,解析失败了——这是我们经常遇到的一个问题。

OutputParser:JsonOutputParser ​

创建 src/json-output-parser.mjs:

js
import 'dotenv/config';
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 question = `请介绍一下爱因斯坦的信息。请以 JSON 格式返回,包含以下字段:name(姓名)、birth_year(出生年份)、nationality(国籍)、famous_theory(著名理论)、major_achievements(主要成就)
${parser.getFormatInstructions()}`;

try {
  console.log("🤔 正在调用大模型(使用 JsonOutputParser)...\n");
  const response = await model.invoke(question);
  console.log("📤 模型原始响应:\n");
  console.log(response.content);

  const result = await parser.parse(response.content);
  console.log("✅ JsonOutputParser 自动解析的结果:\n");
  console.log(result);
  console.log(`姓名: ${result.name}`);
  console.log(`出生年份: ${result.birth_year}`);
  console.log(`国籍: ${result.nationality}`);
  console.log(`著名理论: ${result.famous_theory}`);
  console.log(`主要成就:`, result.major_achievements);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

用 JsonOutputParser,顾名思义,它就是用来解析 json 结果的。就像前面说的:在 prompt 里放一段格式的提示词,然后对返回的结果按照格式来 parse——分别对应 parser.getFormatInstructions 和 parser.parse 方法。

跑一下可以看到:虽然大模型返回的还是带了 markdown 语法,但是 JsonOutputParser 能够解析其中的 json,因为它做了这种常见情况的处理。

有的同学说,getFormatInstructions 好像没内容啊——确实,JsonOutputParser 比较简单,不需要提示词。

OutputParser:StructuredOutputParser ​

换一个 output parser,创建 src/structured-output-parser.mjs:

js
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,
  },
});

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

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

try {
  console.log("🤔 正在调用大模型(使用 StructuredOutputParser)...\n");
  const response = await model.invoke(question);
  console.log("📤 模型原始响应:\n");
  console.log(response.content);

  const result = await parser.parse(response.content);
  console.log("\n✅ StructuredOutputParser 自动解析的结果:\n");
  console.log(result);
  console.log(`姓名: ${result.name}`);
  console.log(`出生年份: ${result.birth_year}`);
  console.log(`国籍: ${result.nationality}`);
  console.log(`著名理论: ${result.famous_theory}`);
  console.log(`主要成就: ${result.major_achievements}`);
} catch (error) {
  console.error("❌ 错误:", error.message);
}

这里我们用 StructuredOutputParser,它可以指定具体的 json 结构:用 fromNamesAndDescriptions 指定字段和描述。跑一下,解析出的对象依然正确,但现在 prompt 里多了一大段提示词——这就是 output parser 的原理:在 prompt 里加入格式描述,根据这个格式来解析响应。

当然,就像我们之前用 zod 来描述 tool 的参数格式一样,StructuredOutputParser 也可以用 zod 来描述复杂的对象格式(fromZodSchema):

js
import { z } from 'zod';

// 使用 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().optional().describe("获奖原因")
    })
  ).describe("获得的重要奖项列表"),
  major_achievements: z.array(z.string()).describe("主要成就列表"),
  famous_theories: z.array(
    z.object({
      name: z.string().describe("理论名称"),
      year: z.number().optional().describe("提出年份"),
      description: z.string().describe("理论简要描述")
    })
  ).describe("著名理论列表"),
  education: z.object({
    university: z.string().describe("主要毕业院校"),
    degree: z.string().describe("学位"),
    graduation_year: z.number().optional().describe("毕业年份")
  }).optional().describe("教育背景"),
  biography: z.string().describe("简短传记,100 字以内")
});

// 从 zod schema 创建 parser
const parser = StructuredOutputParser.fromZodSchema(scientistSchema);

const question = `请介绍一下居里夫人(Marie Curie)的详细信息,包括她的教育背景、研究领域、获得的奖项和著名理论。
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
const result = await parser.parse(response.content);
console.log(JSON.stringify(result, null, 2));

我们用一个 zod 描述了复杂的对象结构(嵌套字段、数组),然后用 StructuredOutputParser 生成提示词并 parse。可以看到它根据格式生成了很大一段提示词,并且解析也是正确的。

Tool Call 方式:可靠性更高 ​

有同学说,tool 可以指定参数的对象格式,能不能直接用 tool 来获取结构化的结果呢?当然可以。创建 src/tool-call-args.mjs:

js
import 'dotenv/config';
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,
  },
});

// 定义结构化输出的 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
  }
]);

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

// 获取结构化结果
const result = response.tool_calls[0].args;
console.log("结构化结果:", JSON.stringify(result, null, 2));
console.log(`\n姓名: ${result.name}`);
console.log(`出生年份: ${result.birth_year}`);
console.log(`国籍: ${result.nationality}`);
console.log(`研究领域: ${result.fields.join(', ')}`);

这里没定义 tool 的实现逻辑,因为我们只是告诉大模型有这个 tool、参数是什么格式,不需要执行。跑一下可以看到,通过返回的 tool_calls 信息,也能拿到结构化的数据。

而且,这种方式比 output parser 更好:因为模型训练的时候就保证了生成 tool calls 的参数一定是符合格式要求的,如果不符合,会重新生成。

那岂不是没必要用 output parser 了?确实,如果只是要求结构化返回数据,用 tool 就行了。

withStructuredOutput:自动选择 ​

现在获取结构化数据一般会用 withStructuredOutput 这个 API:它会判断模型是否支持 tool calls,支持的话就用 tool 的方式获取结构化数据,否则用 output parser 的方式,不用我们自己处理。

js
// 使用 withStructuredOutput 方法
const structuredModel = model.withStructuredOutput(scientistSchema);

// 调用模型
const result = await structuredModel.invoke("介绍一下爱因斯坦");
console.log("结构化结果:", JSON.stringify(result, null, 2));

所以说,现在获取结构化数据更简单了。

那 output parser 一般用不到了?也不是——流式打印返回数据的场景,还是需要 output parser;而且还有一些非 json 格式的,比如 XML、YAML 等格式的内容,也要用 output parser。

流式场景 ​

普通流式 ​

创建 src/stream-normal.mjs:

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';

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 prompt = `详细介绍莫扎特的信息。`;
console.log("🌊 普通流式输出演示(无结构化)\n");

const stream = await model.stream(prompt);
let fullContent = '';
let chunkCount = 0;
console.log("📡 接收流式数据:\n");

for await (const chunk of stream) {
  chunkCount++;
  const content = chunk.content;
  fullContent += content;
  process.stdout.write(content); // 实时显示流式文本
}
console.log(`\n\n✅ 共接收 ${chunkCount} 个数据块\n`);
console.log(`📝 完整内容长度: ${fullContent.length} 字符`);

把 invoke 换成 stream 方法就可以了,用 for await 打印异步返回的 chunk。

withStructuredOutput 流式:不是真流式 ​

先用 withStructuredOutput 做流式结构化输出:

js
const structuredModel = model.withStructuredOutput(schema);
const stream = await structuredModel.stream(prompt);

for await (const chunk of stream) {
  chunkCount++;
  result = chunk;
  console.log(`[Chunk ${chunkCount}]`);
  console.log(JSON.stringify(chunk, null, 2));
}

跑一下可以看到:虽然我们用的是 stream 的流式方式打印的,但是用了 withStructuredOutput 之后,它会在 json 生成完通过校验后再返回(底层是 tool calls)——所以只有一个 chunk 包含完整 json。这样明显不是真的流式。

OutputParser 流式:真流式 ​

创建 src/stream-structured-partial.mjs:

js
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,
  },
});

// 使用 zod 定义结构化输出格式
const schema = z.object({
  name: z.string().describe("姓名"),
  birth_year: z.number().describe("出生年份"),
  death_year: z.number().describe("去世年份"),
  nationality: z.string().describe("国籍"),
  occupation: z.string().describe("职业"),
  famous_works: z.array(z.string()).describe("著名作品列表"),
  biography: z.string().describe("简短传记")
});

const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = `详细介绍莫扎特的信息。\n\n${parser.getFormatInstructions()}`;

console.log("🌊 流式结构化输出演示\n");

const stream = await model.stream(prompt);
let fullContent = '';
let chunkCount = 0;

for await (const chunk of stream) {
  chunkCount++;
  const content = chunk.content;
  fullContent += content;
  process.stdout.write(content); // 实时显示流式文本
}
console.log(`\n\n✅ 共接收 ${chunkCount} 个数据块\n`);

// 解析完整内容为结构化数据
const result = await parser.parse(fullContent);
console.log("📊 解析后的结构化结果:\n");
console.log(JSON.stringify(result, null, 2));

我们用 StructuredOutputParser 解析结果,过程做了流式打印。跑一下可以看到:现在是边生成边打印,最后再 parse。所以流式的情况下,用 output parser 还是更适合的。

Tool Calls 流式:tool_call_chunks ​

那如果我们就是想用 tool calls 来做结构化输出,但还是想要流式的打印,怎么办呢?其实流式输出的情况下,如果你用了 tool call,是这样返回的:tool_call_chunks 里保存了 tool 参数的部分内容,我们可以用这个来实现流式打印效果:

js
const modelWithTool = model.bindTools([
  {
    name: "extract_scientist_info",
    description: "提取和结构化科学家的详细信息",
    schema: scientistSchema
  }
]);

const stream = await modelWithTool.stream("详细介绍牛顿的生平和成就");

for await (const chunk of stream) {
  // 直接打印每个 chunk 的 tool_calls 信息
  if (chunk.tool_call_chunks && chunk.tool_call_chunks.length > 0) {
    process.stdout.write(chunk.tool_call_chunks[0].args);
  }
}

打印 tool_call_chunks 片段,就可以实现流式打印效果。但是这时候是不能调用 tool 的,因为参数还不完整,没有 tool_calls 信息。

如果我想参数不完整的时候,也能拿到 tool_call 参数的 json 呢?这种就可以用 JsonOutputToolsParser 了:它的作用就是解析 tool_call_chunks 中的内容,拼接成符合 json 格式规范的对象,就算 chunk 还没传输完的时候,也能拿到 json 对象:

js
import { JsonOutputToolsParser } from '@langchain/core/output_parsers/openai_tools';

// 1. 绑定工具并挂载解析器
const parser = new JsonOutputToolsParser();
const chain = modelWithTool.pipe(parser);

// 2. 开启流
const stream = await chain.stream("详细介绍牛顿的生平和成就");

for await (const chunk of stream) {
  if (chunk.length > 0) {
    const toolCall = chunk[0];
    console.log(toolCall.args);
  }
}

JsonOutputToolsParser 会尝试解析 tool_call_chunks 生成完整的 tool_calls 信息。跑一下可以看到:就算是流式返回的 tool_call_chunks 还不完整,也会拼成正确格式的 tool_calls——这样你可以实时调用工具,传入部分参数了。

XML 等非 JSON 格式 ​

创建 src/xml-output-parser.mjs:

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { XMLOutputParser } 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 XMLOutputParser();

const question = `请提取以下文本中的人物信息:阿尔伯特·爱因斯坦出生于 1879 年,是一位伟大的物理学家。
${parser.getFormatInstructions()}`;

const response = await model.invoke(question);
console.log("📤 模型原始响应:\n");
console.log(response.content);

const result = await parser.parse(response.content);
console.log("\n✅ XMLOutputParser 自动解析的结果:\n");
console.log(result);

可以看到提示词里加入了一些格式信息,返回的也是 xml 格式,并且正确 parse 了出来。这种也用了 withStructuredOutput(也就是 tool call)来做结构化,还是得用 output parser。

常见问题 ​

withStructuredOutput 支持流式吗? 默认行为下,jsonSchema 模式(非 gpt-3*/gpt-4 系列默认选择)会被官方阻塞流式输出。可以手动指定 method: "functionCalling" 实现流式。注意是否支持流式也和模型有关(比如 glm 就不支持)。

报错 'messages' must contain the word 'json'? 部分国内 API(如阿里云百炼)要求 messages 中必须包含"json"字样才能用 response_format 的 json_object 模式。在 prompt 里加上用 JSON 返回的说明即可。

千问会忽略 schema 自己决定 JSON 格式? 用 withStructuredOutput + json_object 模式时通义千问可能忽略 schema 结构。方案 1:改用 bindTools;方案 2:在 prompt 里严格约束 JSON 结构。

tool 没被调用为什么能拿到 args? 大模型只是返回 tool call 的参数,具体调用是 agent 来做。绑定 20 个工具也不会对 20 个都返回参数——模型只返回它认为当前需要的那个。

JsonOutputToolsParser 增量打印顺序乱? 流式的增量数据并不是末尾的数据,可能是中间的字段,所以用 currentContent.slice(lastContent.length) 输出不连续,直接打印整个 toolCall.args 就好。

API 太多了记不住? API 知道有啥是干啥的就行,跑一遍就可以了,记不住也没啥。

总结 ​

我们经常需要对大模型输出做一些结构化的限制,这时候就需要 output parser 的 API:

  • output parser 的原理:在提示词里加入格式信息,然后对结果做一下 parse。比如 JsonOutputParser、StructuredOutputParser、XMLOutputParser 等
  • tool call 的方式:也完全可以实现结构化限制,而且可靠性更高——模型训练的时候就是保证的
  • 所以,如果是做结构化,直接用 withStructuredOutput 这个 API 就行,它底层就是根据模型来决定是用 tool call 还是 output parser
  • 但它有两个不适合的场景:
    • 流式打印:这种需要用 output parser(边生成边打印,最后 parse)
    • XML 等非 json 格式:也需要 output parser
  • 此外,如果流式打印 tool 参数的过程中,想实时拿到 tool_calls 的 json 对象来调用 tool,可以用 JsonOutputToolsParser 这个 output parser

综上,如果你需要对大模型的输出做结构化,就可以考虑 withStructuredOutput 和 output parser 这两者二选一了。

预览到此为止,输入密码解锁全文

解锁后本机会记住,同密码的其他文章也无需重复输入

基于 VitePress 构建 · 专注前端与 AI 实战