LLM 结构化输出进阶:withStructuredOutput 一行封装 Tool Call,从流式输出到 MySQL 落地
上一篇讲了 LLM 结构化输出的四种方案:JsonOutputParser、fromNamesAndDescriptions、fromZodSchema、Tool Call。其中 Tool Call 是最可靠的------但
model.bindTools的写法有点"讨巧",语义不够清晰。LangChain 提供了一行封装的终极方案:model.withStructuredOutput(schema)。本文从 bindTools 到 withStructuredOutput 的进化讲起,覆盖流式结构化输出、XMLOutputParser 历史遗产、再到 MySQL 建表落库------完整走一遍"LLM 输出结构化数据 → 存入数据库"的工程链路。建议收藏后动手实操。
一、回顾:结构化输出的四种方案
1.1 为什么需要结构化输出
javascript
LLM 输出 = 自由文本字符串
问题:
→ 格式随机(可能带 markdown 代码块)
→ 字段名随机(name 还是"姓名"?)
→ 类型随机(birth_year 是 1879 还是 "1879"?)
→ JSON.parse 直接翻车
目标:
→ 格式确定
→ 字段确定
→ 类型安全
→ 可靠解析
1.2 四种方案的进化
vbnet
┌──────────────────────────────────────────────────────────────┐
│ 结构化输出方案进化链 │
│ │
│ ① JsonOutputParser │
│ → 只说"请输出 JSON" │
│ → 自动剥离 markdown 代码块 │
│ → 不约束字段名和类型 │
│ → 可靠性 ★★ │
│ │
│ ② StructuredOutputParser.fromNamesAndDescriptions │
│ → 字段名 + 描述 │
│ → 但所有值都是 string │
│ → 不支持嵌套结构 │
│ → 可靠性 ★★★ │
│ │
│ ③ StructuredOutputParser.fromZodSchema │
│ → Zod Schema 精确类型 │
│ → 嵌套对象 / 数组 / 可选字段 │
│ → 解析时类型校验 │
│ → 可靠性 ★★★★ │
│ │
│ ④ Tool Call(bindTools) │
│ → 利用模型原生 Tool Calling 能力 │
│ → Schema 通过 API 传递 │
│ → tool_calls[0].args 直接是结构化对象 │
│ → 不需要 parse │
│ → 可靠性 ★★★★★ │
│ → 但写法"讨巧",语义不够清晰 │
│ │
│ ⑤ withStructuredOutput(本文主角) │
│ → 一行封装 Tool Call │
│ → 语义清晰:我要"带结构化输出的模型" │
│ → 支持 invoke / stream / batch │
│ → 底层仍然是 Tool Call │
└──────────────────────────────────────────────────────────────┘
本文主线:
→ Tool Call 的"讨巧写法" → withStructuredOutput 的"语义化封装"
→ 流式结构化输出(一边生成一边出结构化数据)
→ XMLOutputParser(XML 时代的遗产)
→ MySQL 落库(结构化数据的最终归宿)
二、withStructuredOutput:语义化的终极封装
2.1 bindTools 的"讨巧"写法
javascript
// tool-call-args.mjs --- 上一课的 Tool Call 写法
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,
},
});
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 绑定一个"假的"工具 ------ 不是为了调用它
// 而是利用 Tool Call 的 Schema 校验能力
const modelWithTool = model.bindTools([
{
name: 'extract_scientist_info',
description: '提取和结构化科学家的详细信息',
schema: scientistSchema,
},
]);
const response = await modelWithTool.invoke('介绍一下爱因斯坦');
console.log(response.tool_calls[0].args);
// { name: "爱因斯坦", birth_year: 1879, nationality: "德国", fields: ["物理学"] }
vbscript
bindTools 的问题:
┌──────────────────────────────────────────────────────────┐
│ ① 语义不清晰 │
│ → 我们根本不想"调用工具" │
│ → 我们想要"结构化输出" │
│ → 但必须假装定义了一个工具 │
│ → 还要给工具起名、写描述(extract_scientist_info) │
│ │
│ ② 结果藏得深 │
│ → 结果在 response.tool_calls[0].args │
│ → 层层嵌套:response → tool_calls → [0] → args │
│ → 还要担心 tool_calls 可能为空 │
│ → 需要加守卫判断 │
│ │
│ ③ 心智负担 │
│ → 初学者不理解"为什么绑定工具能结构化" │
│ → 代码可读性差 │
│ → 团队协作时容易误解 │
└──────────────────────────────────────────────────────────┘
2.2 withStructuredOutput 一行搞定
javascript
// with-structured-output.mjs --- 语义化升级
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,
},
});
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
// 一行封装:我要一个"带结构化输出的模型"
const structuredModel = model.withStructuredOutput(scientistSchema);
// 调用方式跟普通模型一模一样
const result = await structuredModel.invoke('介绍一下爱因斯坦');
console.log(result);
// { name: '爱因斯坦', birth_year: 1879, nationality: '德国', fields: ['物理学'] }
console.log('------------------');
console.log(JSON.stringify(result, null, 2));
// {
// "name": "爱因斯坦",
// "birth_year": 1879,
// "nationality": "德国",
// "fields": ["物理学"]
// }
ini
withStructuredOutput 的核心改进:
┌──────────────────────────────────────────────────────────┐
│ bindTools(讨巧写法) │
│ │
│ const modelWithTool = model.bindTools([ │
│ { name: '...', description: '...', schema } │
│ ]); │
│ const result = await modelWithTool.invoke(prompt); │
│ const data = response.tool_calls[0].args; // 层层取 │
│ │
│ withStructuredOutput(语义化写法) │
│ │
│ const structuredModel = model.withStructuredOutput( │
│ scientistSchema │
│ ); │
│ const result = await structuredModel.invoke(prompt); │
│ // result 直接就是结构化对象,不用取 │
│ │
│ 差异: │
│ → 不需要给工具起名、写描述 │
│ → 不需要从 tool_calls[0].args 里取 │
│ → invoke 返回的就是结构化对象 │
│ → 语义清晰:这个模型"输出结构化数据" │
└──────────────────────────────────────────────────────────┘
2.3 底层原理:仍然是 Tool Call
scss
withStructuredOutput 只是"糖衣"
┌──────────────────────────────────────────────────────────┐
│ withStructuredOutput 的底层实现 │
│ │
│ model.withStructuredOutput(schema) │
│ │ │
│ │ 内部做了这些事: │
│ │ ① 根据 schema 自动生成工具定义 │
│ │ → 工具名:根据 schema 自动生成 │
│ │ → 工具描述:自动生成 │
│ │ → schema:原样传入 │
│ │ ② 调用 bindTools 绑定工具 │
│ │ → 相当于替你写好了 bindTools │
│ │ ③ 自动解析 tool_calls │
│ │ → invoke 时自动提取 tool_calls[0].args │
│ │ → 直接返回结构化对象 │
│ │ ④ 支持流式输出 │
│ │ → stream() 也能用 │
│ │ │
│ → 本质:bindTools 的封装,省去了重复的样板代码 │
│ → 注释原话:"tool call 讨巧的做法,升级为语义化更好的 │
│ withStructuredOutput,底层 tool call" │
└──────────────────────────────────────────────────────────┘
2.4 bindTools vs withStructuredOutput 对比
| 维度 | bindTools | withStructuredOutput |
|---|---|---|
| 语义 | 绑定工具(讨巧) | 结构化输出(清晰) |
| 工具定义 | 手动写 name + description | 自动生成 |
| 结果获取 | tool_calls[0].args 层层取 |
invoke 直接返回 |
| 空结果处理 | 需手动守卫 | 内部处理 |
| 流式支持 | 需手动处理 | 原生支持 |
| 代码量 | 多 | 少 |
| 可读性 | 中 | 高 |
| 适用场景 | 真的要调用工具 | 只要结构化输出 |
arduino
选型建议:
→ 只要"结构化输出" → withStructuredOutput
→ 真的要"调用工具" → bindTools + 手动执行工具函数
→ withStructuredOutput 不是替代 bindTools
→ 而是"只想拿结构化数据"时的最佳选择
三、流式结构化输出
3.1 为什么需要流式结构化?
css
普通流式输出的问题:
→ LLM 生成的文本逐 chunk 推送
→ 但内容是"自由文本"
→ 不是结构化数据
→ 前端无法按字段消费
流式结构化输出的目标:
→ 一边生成,一边输出结构化数据
→ 用户实时看到结果在"填充"
→ 流结束后拿到完整结构化对象
┌──────────────────────────────────────────────────────────┐
│ 流式结构化 vs 普通流式 │
│ │
│ 普通流式(model.stream) │
│ chunk 1: "莫" │
│ chunk 2: "扎" │
│ chunk 3: "特" │
│ → 是文本片段,不是结构 │
│ │
│ 流式结构化(withStructuredOutput + stream) │
│ chunk 1: { name: "爱", ... } // 部分填充 │
│ chunk 2: { name: "爱因", ... } │
│ chunk 3: { name: "爱因斯坦", birth_year: 1... } │
│ ... │
│ 最终: { name: "爱因斯坦", birth_year: 1879, ... } │
│ → 是结构化的对象,逐步填充 │
└──────────────────────────────────────────────────────────┘
3.2 完整实现
javascript
// stream-with-structured-output.mjs --- 流式结构化输出
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,
},
});
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.array(z.string()).describe('简短传记'),
});
// 一行:结构化输出 + 流式
const structuredModel = model.withStructuredOutput(schema);
const prompt = '详细介绍莫扎特的信息。';
console.log('流式 结构化输出演示');
try {
const stream = await structuredModel.stream(prompt);
let chunkCount = 0;
let result = null;
console.log('接收流式数据...\n');
// for await 消费结构化流
for await (const chunk of stream) {
chunkCount++;
result = chunk; // 最后一个 chunk 是完整结构化结果
console.log(JSON.stringify(chunk, null, 2));
}
console.log('\n共接收', chunkCount, '个数据块');
console.log('最终结构化结果:');
console.log(result);
} catch (err) {
console.error('流式结构化输出出错:', err);
}
javascript
代码解析:
① model.withStructuredOutput(schema)
→ 结构化输出模型
→ 同时支持 invoke 和 stream
② structuredModel.stream(prompt)
→ 流式调用
→ 返回异步可迭代对象
→ 每个 chunk 是部分结构化数据
③ for await (const chunk of stream)
→ 逐个消费 chunk
→ JSON.stringify 方便查看
④ result = chunk
→ 最后一次赋值是完整结果
→ 流结束后 result 是完整的结构化对象
⑤ 与普通 stream 的区别
→ 普通 stream:chunk 是文本片段
→ 结构化 stream:chunk 是部分结构数据
→ 最终结果类型安全
3.3 流式结构化的应用场景
arduino
流式结构化输出的场景:
① 实时表单填充
→ AI 生成内容时,表单字段实时填充
→ 用户看到"正在填写"的过程
→ 体验更流畅
② 实时信息展示
→ 电影信息卡片逐步渲染
→ 人物简介逐步完善
→ 减少等待感
③ 长内容结构化
→ 生成报告时边生成边出结构化
→ 每生成一段就结构化一段
→ 最终完整对象
④ AI Agent 工具编排
→ Agent 调用工具的参数逐步生成
→ 用户看到参数在"组装"
→ 可提前取消或干预
四、XMLOutputParser:XML 时代的数据交换遗产
4.1 XML vs JSON:数据交换标准演进
javascript
XML 时代:
→ XML(Extensible Markup Language)可扩展标记语言
→ 老一代的数据交换标准
→ 结构严谨,标签成对
<h1>title<span>副标题<b>文强恋爱了</b></span></h1>
→ 标签嵌套表示层级
→ 有开必有闭
→ 机器可读,但冗长
JSON 时代:
→ JSON(JavaScript Object Notation)
→ 现代数据交换的事实标准
→ 轻量、简洁、易读
fetch 后端 API → 返回 JSON 格式
→ 前后端分离的默认格式
→ 浏览器原生 JSON.parse
┌──────────────────────────────────────────────────────────┐
│ XML vs JSON │
│ │
│ XML JSON │
│ ────────────────────────────────────────── │
│ 格式 <tag>value</tag> "key": value │
│ 可读性 较差 好 │
│ 体积 大(标签冗余) 小 │
│ 解析 需要解析器 JSON.parse │
│ 类型 全文本 有类型 │
│ 注释 支持 不支持 │
│ 命名空间 支持 不支持 │
│ 标准 老一代 现代事实标准 │
│ 历史角色 数据交换初代标准 数据交换事实标准 │
│ 现状 配置文件、SOAP 等 API 数据交换默认 │
└──────────────────────────────────────────────────────────┘
4.2 XMLHttpRequest 名字的由来
javascript
为什么叫 XMLHttpRequest?它跟 XML 有关系吗?
→ 2000 年前后,XML 是数据交换标准
→ IE 5 推出了 XMLHttpRequest 对象
→ 最初的设计就是"用 XML 传输数据"
→ 名字里带着 XML
但后来:
→ 实际使用中 JSON 取代了 XML
→ XHR 传 JSON 也完全没问题
→ 名字保留了下来(历史包袱)
→ 所以现在用 XHR 时根本看不到 XML
类似的历史遗产:
→ .js 后缀(JavaScript,不是 Java)
→ npm(Node Package Manager,不止管理 Node 包)
→ AJAX(Asynchronous JavaScript And XML,早就不传 XML 了)
┌──────────────────────────────────────────────────────────┐
│ XMLHttpRequest 名字的真相 │
│ │
│ XMLHttpRequest = 老时代数据交换的产物 │
│ → 最初设计:用 XML 做 AJAX 数据格式 │
│ → 现实发展:JSON 取代 XML │
│ → 名字保留:历史包袱,约定俗成 │
│ → 今天的 XHR:fetch 的底层,传 JSON │
│ │
│ 注释原话: │
│ "fetch 后端api, 返回json 格式 数据交换的事实标准 │
│ XMLHttpRequest 老时代数据交换 xml ajax" │
└──────────────────────────────────────────────────────────┘
4.3 XMLOutputParser 完整实现
javascript
// xml-output-parser.mjs --- XML 结构化输出
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()}
`;
console.log(question);
try {
console.log('正在调用大模型....\n');
const response = await model.invoke(question);
console.log(response.content);
// XML → JavaScript 对象
const result = await parser.parse(response.content);
console.log(result);
} catch (err) {
console.error('解析错误:', err);
}
xml
XMLOutputParser 的作用:
┌──────────────────────────────────────────────────────────┐
│ getFormatInstructions() │
│ → 告诉 LLM "请按 XML 格式输出" │
│ → 生成 XML Schema 指令 │
│ → 拼接到 prompt 末尾 │
│ │
│ parser.parse(response.content) │
│ → 接收 LLM 的 XML 文本输出 │
│ → 解析成 JavaScript 对象 │
│ → 类似 JSON.parse 但针对 XML │
│ │
│ 输出示例: │
│ LLM 返回: │
│ <tasks> │
│ <task> │
│ <name>爱因斯坦</name> │
│ <year>1879</year> │
│ </task> │
│ </tasks> │
│ │
│ 解析后: │
│ { │
│ tasks: { │
│ task: { │
│ name: "爱因斯坦", │
│ year: "1879" │
│ } │
│ } │
│ } │
└──────────────────────────────────────────────────────────┘
为什么还要学 XML 输出?
→ 了解历史:XML 曾是数据交换标准
→ 兼容旧系统:某些老系统仍用 XML
→ 完整认知:JSON 不是唯一的结构化格式
→ 面试谈资:XMLHttpRequest 名字的由来
4.4 结构化输出方案全景
sql
现在共有六种结构化方案:
┌──────────────────────────────────────────────────────────┐
│ Output Parser 家族 │
│ ├── JsonOutputParser(最简单) │
│ ├── StructuredOutputParser.fromNamesAndDescriptions │
│ ├── StructuredOutputParser.fromZodSchema(天花板) │
│ └── XMLOutputParser(XML 格式) │
│ │
│ Tool 家族 │
│ ├── model.bindTools(底层,讨巧写法) │
│ └── model.withStructuredOutput(语义化封装) │
│ │
│ 选型: │
│ → 常规生产:withStructuredOutput + Zod │
│ → 需要流式:withStructuredOutput + stream │
│ → 老系统对接:XMLOutputParser │
│ → 模型不支持 Tool Call:fromZodSchema │
└──────────────────────────────────────────────────────────┘
五、MySQL 数据落地:建库建表
5.1 mysql2/promise
javascript
// create-table.mjs --- MySQL 建库建表
import mysql from 'mysql2/promise';
async function main() {
const connectionConfig = {
host: 'localhost',
port: 3307, // Docker 映射端口(上篇文章的 MySQL 容器)
user: 'root',
password: '123456',
multipleStatements: true,
};
const connection = await mysql.createConnection(connectionConfig);
try {
// ① 创建数据库(不存在才创建)
await connection.query(`
CREATE DATABASE IF NOT EXISTS hello CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
`);
// ② 切换到数据库
await connection.query('USE hello;');
// ③ 创建表(不存在才创建)
await connection.query(`
CREATE TABLE IF NOT EXISTS friends (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
gender VARCHAR(10), -- 性别
birth_date DATE, -- 出生日期
company VARCHAR(100), -- 公司
title VARCHAR(100), -- 职位
phone VARCHAR(20), -- 当前手机号
wechat VARCHAR(50) -- 微信号
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
console.log('数据库和表创建成功');
} catch (err) {
console.error('建库建表失败:', err);
} finally {
// ④ 关闭连接
await connection.end();
}
}
main().catch((err) => console.error('执行失败:', err));
javascript
mysql2/promise 是什么?
┌──────────────────────────────────────────────────────────┐
│ mysql2 的两个版本 │
│ │
│ mysql2(回调版) │
│ → const mysql = require('mysql2') │
│ → connection.query(sql, callback) │
│ → 回调地狱问题 │
│ │
│ mysql2/promise(Promise 版) │
│ → import mysql from 'mysql2/promise' │
│ → await connection.query(sql) │
│ → 配合 async/await 使用 │
│ → 现代写法,推荐使用 │
│ │
│ 本文使用 Promise 版 │
│ → 所有操作都是 await │
│ → 代码更清晰 │
└──────────────────────────────────────────────────────────┘
5.2 连接配置解析
yaml
connectionConfig 配置项:
host: 'localhost'
→ MySQL 服务器地址
→ 本机连接用 localhost
port: 3307
→ MySQL 端口
→ 默认是 3306
→ 3307 说明是 Docker 容器映射的端口
→ (上篇 Docker 文章:3307:3306 映射)
user: 'root'
→ MySQL 用户名
password: '123456'
→ 密码
→ 生产环境用环境变量,不要硬编码!
multipleStatements: true
→ 允许一次执行多条 SQL 语句
→ 用 ; 分隔
→ 注意:也会带来 SQL 注入风险,生产慎用
5.3 建库:CREATE DATABASE
sql
CREATE DATABASE IF NOT EXISTS hello CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
sql
建库语句解析:
CREATE DATABASE
→ 创建数据库
IF NOT EXISTS
→ 已存在则不创建
→ 幂等操作(执行多次结果一样)
→ 脚本可重复运行
CHARACTER SET utf8mb4
→ 字符集 utf8mb4
→ 支持所有 Unicode 字符(包括 emoji)
→ 4 字节 UTF-8 编码
→ 现代 MySQL 的事实标准
COLLATE utf8mb4_unicode_ci
→ 排序规则
→ utf8mb4 的 Unicode 通用排序
→ ci = case insensitive(不区分大小写)
为什么用 utf8mb4 而不是 utf8?
→ MySQL 的 utf8 其实是 utf8mb3(3 字节)
→ 存不了 emoji 和生僻字
→ utf8mb4 才是完整的 UTF-8
→ 从 MySQL 5.5.3 开始支持,现在是默认推荐
5.4 建表:CREATE TABLE
sql
CREATE TABLE IF NOT EXISTS friends (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
gender VARCHAR(10),
birth_date DATE,
company VARCHAR(100),
title VARCHAR(100),
phone VARCHAR(20),
wechat VARCHAR(50)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql
friends 表字段设计:
┌────────────┬────────────────────────────────────────────┐
│ 字段 类型 + 约束 说明 │
├────────────┼────────────────────────────────────────────┤
│ id INT AUTO_INCREMENT 主键,自增 │
│ PRIMARY KEY │
│ name VARCHAR(50) NOT NULL 姓名,必填 │
│ gender VARCHAR(10) 性别 │
│ birth_date DATE 出生日期 │
│ company VARCHAR(100) 公司 │
│ title VARCHAR(100) 职位 │
│ phone VARCHAR(20) 当前手机号 │
│ wechat VARCHAR(50) 微信号 │
└────────────┴────────────────────────────────────────────┘
设计要点:
① id INT AUTO_INCREMENT PRIMARY KEY
→ 整数自增主键
→ 每插入一行自动 +1
→ 唯一标识每一行
→ 数据库自动生成,无需手动指定
② name VARCHAR(50) NOT NULL
→ 姓名,最多 50 字符
→ NOT NULL:必填字段
→ 不能为空
③ gender VARCHAR(10)
→ 性别,没有 NOT NULL
→ 可选字段
→ 可以为 NULL
④ birth_date DATE
→ 日期类型
→ 不是字符串!是真正的日期类型
→ 可以用日期函数处理
⑤ 手机号/微信号用 VARCHAR
→ 不用数字类型!
→ 因为:可能带 +86 前缀、可能有空格
→ 数字类型会丢前导零
→ 电话/ID 类数据一律用字符串
ini
表级设置:
ENGINE=InnoDB
→ 存储引擎
→ InnoDB:支持事务、外键、行级锁
→ MySQL 5.5+ 默认引擎
→ 生产环境标准选择
DEFAULT CHARSET=utf8mb4
→ 表默认字符集
→ 与数据库一致
→ 保证中文、emoji 都能存
5.5 建表后的数据操作
javascript
// 插入数据
await connection.query(`
INSERT INTO friends (name, gender, birth_date, company, title)
VALUES (?, ?, ?, ?, ?)
`, ['张三', '男', '1990-01-01', '字节跳动', '前端工程师']);
// 查询数据
const [rows] = await connection.query(`
SELECT * FROM friends WHERE company = ?
`, ['字节跳动']);
console.log(rows);
sql
注意参数化查询:
✅ 正确写法(参数化):
connection.query('SELECT * FROM friends WHERE id = ?', [1])
❌ 错误写法(字符串拼接):
connection.query(`SELECT * FROM friends WHERE id = ${id}`)
→ ? 是占位符
→ 参数通过数组传入
→ 防止 SQL 注入
→ 这是安全底线!
六、完整链路:LLM 结构化输出 → MySQL 存储
6.1 场景设计
sql
场景:AI 人物信息提取 + 落库
流程:
┌──────────────────────────────────────────────────────────┐
│ │
│ 用户输入文本 │
│ "爱因斯坦生于1879年,是一位伟大的物理学家..." │
│ │ │
│ ▼ │
│ withStructuredOutput 提取结构化数据 │
│ → { name, birth_year, nationality, fields } │
│ │ │
│ ▼ │
│ MySQL 建表(friends/scientists) │
│ → CREATE TABLE IF NOT EXISTS ... │
│ │ │
│ ▼ │
│ INSERT 落库 │
│ → 参数化插入 │
│ │ │
│ ▼ │
│ 查询验证 │
│ → SELECT * FROM scientists │
│ │
│ = LLM 结构化输出 → 数据库持久化 │
└──────────────────────────────────────────────────────────┘
6.2 完整代码
javascript
import 'dotenv/config';
import mysql from 'mysql2/promise';
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 },
});
const scientistSchema = z.object({
name: z.string().describe('科学家的姓名'),
birth_year: z.number().describe('出生年份'),
nationality: z.string().describe('国籍'),
fields: z.array(z.string()).describe('研究领域列表'),
});
const structuredModel = model.withStructuredOutput(scientistSchema);
// ② 建表 + 落库
async function saveToMySQL(scientist) {
const connection = await mysql.createConnection({
host: 'localhost',
port: 3307,
user: 'root',
password: process.env.MYSQL_PASSWORD,
});
try {
await connection.query(`CREATE DATABASE IF NOT EXISTS ai_data
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
await connection.query('USE ai_data');
await connection.query(`
CREATE TABLE IF NOT EXISTS scientists (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
birth_year INT,
nationality VARCHAR(50),
fields VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await connection.query(`
INSERT INTO scientists (name, birth_year, nationality, fields)
VALUES (?, ?, ?, ?)
`, [
scientist.name,
scientist.birth_year,
scientist.nationality,
scientist.fields.join(','),
]);
console.log('已存入数据库:', scientist.name);
} catch (err) {
console.error('落库失败:', err);
} finally {
await connection.end();
}
}
// ③ 主流程
async function main() {
const result = await structuredModel.invoke('介绍一下爱因斯坦');
console.log('结构化输出:', result);
await saveToMySQL(result);
}
main().catch(console.error);
sql
完整链路总结:
① withStructuredOutput(schema)
→ LLM 输出结构化数据(类型安全)
② CREATE DATABASE / CREATE TABLE
→ 准备存储结构(幂等,可重复运行)
③ INSERT 参数化插入
→ 结构化数据落库(防止 SQL 注入)
④ SELECT 查询
→ 验证数据已持久化
→ 这就是 LLM 应用落地的标准链路:
自由文本 → 结构化 → 数据库
七、总结
7.1 知识体系图
sql
LLM 结构化输出进阶
│
├── withStructuredOutput(语义化终极封装)
│ ├── 一行:model.withStructuredOutput(schema)
│ ├── 底层:封装了 Tool Call(bindTools 的糖衣)
│ ├── 支持 invoke / stream / batch
│ ├── 结果直接返回结构化对象(不用取 tool_calls)
│ └── vs bindTools:语义清晰、代码少、可读性高
│
├── 流式结构化输出
│ ├── structuredModel.stream(prompt)
│ ├── 每个 chunk 是部分结构化数据
│ ├── 最后一个 chunk 是完整结果
│ ├── 实时填充表单/卡片
│ └── 最终类型安全
│
├── XMLOutputParser(历史遗产)
│ ├── XML:老一代数据交换标准
│ ├── JSON:现代数据交换事实标准
│ ├── XMLHttpRequest 名字的由来(历史包袱)
│ ├── parser.parse(xml) → JavaScript 对象
│ └── 场景:兼容老系统
│
├── MySQL 落库
│ ├── mysql2/promise(Promise 版 API)
│ ├── connectionConfig(host/port/user/password)
│ ├── CREATE DATABASE IF NOT EXISTS
│ │ ├── utf8mb4(完整 UTF-8,支持 emoji)
│ │ └── utf8mb4_unicode_ci(排序规则)
│ ├── CREATE TABLE IF NOT EXISTS
│ │ ├── INT AUTO_INCREMENT PRIMARY KEY
│ │ ├── VARCHAR(n) NOT NULL
│ │ ├── DATE(日期类型)
│ │ ├── ENGINE=InnoDB(事务/行锁)
│ │ └── DEFAULT CHARSET=utf8mb4
│ └── 参数化查询(? 占位符,防 SQL 注入)
│
├── 完整链路
│ ├── 用户输入自由文本
│ ├── withStructuredOutput 提取结构化数据
│ ├── MySQL 建表(幂等)
│ ├── INSERT 参数化落库
│ └── SELECT 验证持久化
│
└── 核心认知
├── withStructuredOutput = Tool Call 的语义化封装
├── 流式结构化 = 实时性 + 类型安全
├── XML 是历史,JSON 是现在
└── 结构化输出的终点是"数据落地"
7.2 一句话总结
withStructuredOutput 是 Tool Call 的语义化封装:
model.withStructuredOutput(schema)一行代码,底层自动完成"定义工具 → 绑定工具 → 解析 tool_calls"的全部流程,invoke 直接返回类型安全的结构化对象,还支持 stream() 流式结构化输出------每个 chunk 是部分结构数据,最后一个 chunk 是完整结果。再加上 XMLOutputParser 了解 XML 时代的数据交换遗产(XMLHttpRequest 名字的由来),以及 MySQL 的建库建表(utf8mb4 + InnoDB + 参数化查询),就构成了 LLM 应用的完整数据链路:自由文本 → 结构化输出 → 数据库持久化。用一句话概括:结构化输出解决"AI 输出不可控"的问题,数据库解决"数据不落地"的问题,两者结合才是 LLM 应用的工程化闭环。
如果这篇文章对你有帮助,欢迎点赞 和收藏!