第三章 05:3.7 结构化输出与程序化消费
课程专题 :《AI Agents 开发实践》
原文链接 :
https://s.juejin.cn/ds/TAqu_hbSmd8/章节 :第三章 3.7 结构化输出与程序化消费
作者:Java 开发者转型 AI 全栈
3.7.1 自由文本输出的局限
前面几节已经把调用流程整理得更清楚了,但如果模型输出仍然是一段自由文本,它进入程序后的价值仍然有限。
问题 :3.6 的 chain-invoke 返回的 result 是字符串:
json
{
"result": "{\n \"action\": \"绑定手机号\",\n \"constraints\": [\"必须\", \"至少 8 位\"],\n \"entities\": [\"用户\", \"手机号\", \"密码\"]\n}"
}
注意 result 的值------它长得像 JSON,但本质仍是字符串 (外层有引号)。如果模型哪天多输出一句"希望对你有帮助",你的 JSON.parse() 就崩了。
3.7.2 共享字段结构的定义
因此,下一步要做的,就是先把字段结构明确下来,并沉淀到共享 contract 中。
Java 对照
java
// 反面:模型返回字符串,手动解析(脆弱)
String modelOutput = callModel(prompt); // "{\"action\":\"注册\",...}"
JSONObject json = new JSONObject(modelOutput); // 模型多输出一句就崩
String action = json.getString("action");
// 正面:用 DTO + Jackson 反序列化(类型安全)
public class RequirementResult {
private String action;
private List<String> constraints;
private List<String> entities;
// getter/setter
}
public RequirementResult extract(String input) {
String modelOutput = callModel(prompt);
return objectMapper.readValue(modelOutput, RequirementResult.class);
// 字段类型稳定,程序可断言
}
LangChain 做法
用 zod 定义 schema(相当于 Java 的 DTO),把 schema 放到 @autix/contracts 共享包(前后端共用同一份结构)。
修改 packages/contracts/src/index.ts
在文件末尾追加(先确保已安装 zod:bun add zod --cwd packages/contracts):
typescript
// ═══════════════════════════════════════════════════════════
// 3.7 结构化输出:需求抽取契约(schema 即契约,前后端共用)
// ═══════════════════════════════════════════════════════════
import { z } from 'zod';
/** 需求抽取入参 */
export const RequirementSchema = z.object({
input: z.string().min(1),
});
/** 需求抽取结构化结果 */
export const RequirementResultSchema = z.object({
action: z.string().describe('唯一核心动作'),
constraints: z.array(z.string()).describe('明确约束条件'),
entities: z.array(z.string()).describe('关键实体'),
});
export type RequirementResult = z.infer<typeof RequirementResultSchema>;
3.7.3 withStructuredOutput() 的使用方式
有了共享 schema 之后,就可以让模型直接朝着这个结构输出,而不是再依赖后置字符串清洗。
services/chat/src/llm/requirement.service.ts
typescript
// services/chat/src/llm/requirement.service.ts
// 3.7 结构化输出 ------ 让模型直接朝着共享 schema 输出,而不是依赖后置字符串清洗
import { Injectable } from '@nestjs/common';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import {
RequirementResultSchema,
type RequirementResult,
} from '@autix/contracts';
import { createChatModel } from './model.factory';
import {
REQUIREMENT_SYSTEM_PROMPT,
REQUIREMENT_USER_TEMPLATE,
} from './prompts/requirement.prompt';
@Injectable()
export class RequirementService {
private model = createChatModel();
private prompt = ChatPromptTemplate.fromMessages([
['system', REQUIREMENT_SYSTEM_PROMPT],
['human', REQUIREMENT_USER_TEMPLATE],
]);
async extract(input: string): Promise<RequirementResult> {
const messages = await this.prompt.formatMessages({ input });
const structuredModel = this.model.withStructuredOutput(
RequirementResultSchema,
);
return structuredModel.invoke(messages);
}
}
修改 services/chat/src/llm/llm.module.ts
把 RequirementService 加到 providers 和 exports:
typescript
import { Module } from '@nestjs/common';
import { LlmService } from './llm.service';
import { LlmController } from './llm.controller';
import { RequirementService } from './requirement.service';
@Module({
providers: [LlmService, RequirementService],
controllers: [LlmController],
exports: [LlmService, RequirementService],
})
export class LlmModule {}
修改 services/chat/src/llm/llm.controller.ts
顶部加 import:
typescript
import { RequirementService } from './requirement.service';
构造函数加注入:
typescript
constructor(
private readonly llmService: LlmService,
private readonly requirementService: RequirementService,
) {}
类里新增路由(放在最后):
typescript
// 3.7.4 POST /api/langchain/structured(结构化输出,直接返回 schema 对象)
@Post('structured')
async structured(@Body() body: { input: string }) {
return this.requirementService.extract(body.input);
}
3.7.4 结构化输出的工程价值
验收测试
cmd
curl.exe -s -X POST http://localhost:4001/api/langchain/structured -H "Content-Type: application/json" -d "{\"input\":\"用户注册时必须绑定手机号,密码至少 8 位\"}"
期望返回:
json
{
"action": "绑定手机号",
"constraints": ["必须", "至少 8 位"],
"entities": ["用户", "手机号", "密码"]
}
关键对比
| 3.6 chain-invoke | 3.7 structured |
|---|---|
{"result":"{\n \"action\": ..."} |
{"action":"绑定手机号","constraints":[...],"entities":[...]} |
| result 是字符串(外层有引号) | 直接是 JSON 对象(无外层引号) |
程序需要 JSON.parse(result) |
程序直接 result.action 访问 |
| 模型多输出一句就崩 | 字段类型由 zod schema 保证稳定 |
这就是 withStructuredOutput() 的核心价值:模型输出从"自由文本"变成"可被程序稳定消费的结构"。
工程价值
到这里,模型结果才真正开始具备"可被程序稳定消费"的条件。
- 可断言 :测试里可以写
expect(result.action).toBe('绑定手机号') - 可消费 :下游代码可以直接
result.constraints.map(...) - 可测试:回归测试可以批量跑,对比前后输出差异
注意:解析兜底
即使已经声明返回 JSON,也仍可能出现输出夹杂解释文本的情况。这种问题需要通过提示约束、解析兜底和测试样例一起处理。
提示约束:在 system prompt 里明确要求"输出必须符合 schema,不要输出解释"
解析兜底 :如果模型偶尔还是输出了解释文本,可以在 Service 层加一个 JSON 提取逻辑(从输出中截取第一个 {...} 块)
测试样例:准备一组典型输入,每次改 prompt 或换模型后批量重跑,对比输出差异
判断标准
什么时候该上结构化输出?
当模型结果需要作为接口返回值、下游输入或测试断言的一部分,而不是仅供人类阅读时。
下一章
3.8 工具调用机制的接入:让模型不只生成内容,还能主动调用外部函数。
敬请期待!
回到索引 :第三章博客索引