利用 LLM 返回的 Tool Call,顺手完成结构化输出

62 阅读4分钟

如果只是和用户聊天,大模型返回自然语言就够了。

但程序更希望拿到:

{
  name: 'Albert Einstein',
  birth_year: 1879,
  nationality: 'German'
}

而不是:

爱因斯坦出生于 1879 年,是一位德国物理学家……

这就是结构化输出要解决的问题:

让模型输出从“给人阅读的文本”,变成“程序可以稳定处理的数据”。


一、最简单的方法:要求模型返回 JSON

可以直接在 Prompt 中写:

const prompt = `
请介绍一下爱因斯坦。

以 JSON 格式返回,包含:

name
birth_year
nationality
major_achievement
`;

模型可能返回:

{
  "name": "Albert Einstein",
  "birth_year": 1879,
  "nationality": "German",
  "major_achievement": [
    "Theory of Relativity"
  ]
}

然后:

const result =
  JSON.parse(response.content);

这样字符串就变成了 JavaScript 对象。

但问题也很明显。

模型可能返回:

```json
{
  "name": "Albert Einstein"
}
```

也可能夹杂解释文字。

这时:

JSON.parse()

就可能直接失败。


二、JsonOutputParser

LangChain 提供:

import {
  JsonOutputParser
} from '@langchain/core/output_parsers';

创建解析器:

const parser =
  new JsonOutputParser();

然后把格式要求加入 Prompt:

const prompt = `
请介绍一下爱因斯坦。

${parser.getFormatInstructions()}
`;

调用模型:

const response =
  await model.invoke(prompt);

最后:

const result =
  await parser.parse(response.content);

流程变成:

Prompt
  ↓
要求输出 JSONLLMJSON 文本
  ↓
JsonOutputParserJavaScript Object

但这仍然只能解决:

是不是合法 JSON

不能保证:

必须有哪些字段
字段是什么类型

三、StructuredOutputParser

于是约束进一步升级。

import {
  StructuredOutputParser
} from '@langchain/core/output_parsers';

可以声明字段:

const parser =
  StructuredOutputParser
    .fromNamesAndDescriptions({
      name: '姓名',
      birth_year: '出生年份',
      nationality: '国籍',
      major_achievement:
        '主要成就'
    });

然后:

const prompt = `
请介绍一下爱因斯坦。

${parser.getFormatInstructions()}
`;

这次模型不仅知道:

返回 JSON

还知道:

JSON 应该有哪些字段
每个字段是什么意思

结构约束更明确了。


四、但字段名称正确还不够

假设我们希望:

birth_year

必须是:

number

结果模型返回:

{
  "birth_year": "1879"
}

这依然是合法 JSON。

甚至字段名也是对的。

但类型错了。

真实业务通常还会要求:

name 必须是 string

birth_year 必须是 number

major_achievement 必须是 string[]

某些字段可选

某些字段又是嵌套对象

所以真正需要的是:

Schema

五、Zod:给数据定义 Schema

Zod 是 JavaScript / TypeScript 中常见的 Schema 校验库。

例如:

import { z } from 'zod';

const schema = z.object({
  name: z.string(),

  birth_year: z.number(),

  nationality: z.string(),

  major_achievement:
    z.array(z.string()),

  famous_theory:
    z.string().optional()
});

它表达的是:

整个结果必须是 object

name
→ string

birth_year
→ number

nationality
→ string

major_achievement
→ string[]

famous_theory
→ 可选 string

Zod 的核心作用一句话就够:

定义数据应该长什么样,并在运行时验证数据。

例如:

schema.parse({
  name: 'Einstein',
  birth_year: '1879'
});

这里:

birth_year

本来要求:

number

却传入:

string

Zod 会直接发现类型不符合 Schema。


六、在 LangChain 中使用 Zod

可以把 Zod Schema 交给:

StructuredOutputParser

例如:

const schema = z.object({
  name: z.string()
    .describe('姓名'),

  birth_year: z.number()
    .describe('出生年份'),

  nationality: z.string()
    .describe('国籍'),

  major_achievement:
    z.array(z.string())
      .describe('主要成就')
});

const parser =
  StructuredOutputParser
    .fromZodSchema(schema);

然后:

const prompt = `
请介绍一下爱因斯坦。

${parser.getFormatInstructions()}
`;

调用:

const response =
  await model.invoke(prompt);

const result =
  await parser.parse(response.content);

现在约束已经从:

JSON

升级到了:

具体字段
+
具体类型
+
数据结构

七、Tool Calling 又做了什么

前面的 Parser 思路,本质是:

让模型生成一段文本
    ↓
文本里包含 JSON
    ↓
再解析 JSON

另一种思路是:

不让模型“写 JSON 文本”,而是让模型生成符合工具参数定义的结构化参数。

例如定义:

const weatherTool = {
  name: 'get_weather',

  description: '获取天气',

  schema: z.object({
    city: z.string()
  })
};

模型需要调用工具时,可能产生:

{
  name: 'get_weather',
  args: {
    city: '杭州'
  }
}

这里:

args

已经是结构化数据。

因此 Tool Calling 也经常被用于结构化输出。


八、现在更直接的写法:withStructuredOutput()

如果目的不是调用真实工具,只是:

我希望模型按照 Schema 返回数据。

那么现在可以直接:

const schema = z.object({
  name: z.string(),
  birth_year: z.number(),
  nationality: z.string(),
  major_achievement:
    z.array(z.string())
});

然后:

const structuredModel =
  model.withStructuredOutput(schema);

调用:

const result =
  await structuredModel.invoke(
    '介绍一下爱因斯坦'
  );

拿到的结果直接可以按照 Schema 使用:

console.log(result.name);
console.log(result.birth_year);

代码已经从:

LLM
↓
字符串
↓
Parser
↓
Object

逐渐演进成:

Schema
↓
LLM
↓
Structured Object

九、整个结构化输出主线其实很简单

从最开始一路看下来:

自然语言
    ↓
要求 JSON
    ↓
JSON.parse()
    ↓
JsonOutputParser
    ↓
StructuredOutputParser
    ↓
Zod Schema
    ↓
Tool Calling
    ↓
withStructuredOutput()

每一步都只是在解决同一个问题:

如何让大模型输出越来越稳定、越来越适合程序直接使用。

可以把几个方案简单理解成:

JSON
→ 有格式

StructuredOutputParser
→ 有字段

Zod
→ 有字段 + 有类型

withStructuredOutput()
→ 直接把结构要求交给模型调用

真正开发新项目时,如果当前模型支持结构化输出,通常优先考虑:

model.withStructuredOutput(schema);

而了解前面的 Parser 演进,是为了真正理解:

Schema 为什么存在
Parser 在解决什么
结构化输出究竟比普通 JSON 强在哪里

最终目的不是“学会更多 API”,而是让模型输出的数据能够真正进入业务代码。