Skip to content

Output Parser 实战:智能录入 + 流式版 mini cursor

前面学了输出控制:用 model.withStructuredOutput 来控制输出的结构,它底层会根据模型来决定用 tool 或者 output parser,确保输出一定是符合格式要求的。一般用 withStructuredOutput 就可以了,但当流式返回内容的时候,如果要实现打字机效果,就要直接用 output parser 了,比如 tool 参数的流式打印。

这节我们来练习一下,做两个实战:智能录入流式版 mini cursor

实战一:智能录入

场景

当你需要录入信息的时候,之前怎么做呢?一般是在表单里填入信息,点击保存;如果是批量录入,可以上传 excel 来解析录入——这需要你把数据按结构整理好,代码里解析出来保存到数据库。

但在 AI 时代,一般都是智能录入:你只需要给一段文本,让 AI 分析并提取其中的数据,按照结构整理好,然后插入数据库。这是 AI 应用常见功能,就需要用 withStructuredOutput 实现大模型的结构化输出控制。

准备 MySQL

先安装 MySQL 数据库,用 Docker(需要指定 MYSQL_ROOT_PASSWORD 这个环境变量,它是 root 用户的密码)。然后下载一个 GUI 工具连上它,用 MySQL 官方的 MySQL Workbench(https://dev.mysql.com/downloads/workbench/)。

在 output-parser-test 那个项目里写,创建 src/test/create-table.mjs

js
import mysql from "mysql2/promise";

async function main() {
  const connectionConfig = {
    host: "localhost",
    port: 3306,
    user: "root",
    password: "admin",
    multipleStatements: true,
  };

  const connection = await mysql.createConnection(connectionConfig);
  try {
    // 创建 database
    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;
    `);

    // 插入 demo 数据
    const insertSql = `
      INSERT INTO friends (
        name, gender, birth_date, company, title, phone, wechat
      ) VALUES (?, ?, ?, ?, ?, ?, ?);
    `;
    const values = [
      "王经理",      // name
      "男",          // gender
      "1990-01-01",  // birth_date
      "字节跳动",     // company
      "产品经理/产品总监", // title
      "18612345678", // phone
      "wangjingli2024", // wechat
    ];

    const [result] = await connection.execute(insertSql, values);
    console.log("成功创建数据库和表,并插入 demo 数据,插入 ID:", result.insertId);
  } catch (err) {
    console.error("执行出错:", err);
  } finally {
    await connection.end();
  }
}

main().catch((err) => {
  console.error("脚本运行失败:", err);
});

mysql2 的驱动包来连接数据库:

bash
pnpm install mysql2

这里创建了 database,创建了一个好友表,然后插入了一条数据。

智能录入

创建 src/test/smart-import.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
import mysql from 'mysql2/promise';

// 初始化模型
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 schema,匹配 friends 表结构
const friendSchema = z.object({
  name: z.string().describe('姓名'),
  gender: z.string().describe('性别(男/女)'),
  birth_date: z.string().describe('出生日期,格式:YYYY-MM-DD,如果无法确定具体日期,根据年龄估算'),
  company: z.string().nullable().describe('公司名称,如果没有则返回 null'),
  title: z.string().nullable().describe('职位/头衔,如果没有则返回 null'),
  phone: z.string().nullable().describe('手机号,如果没有则返回 null'),
  wechat: z.string().nullable().describe('微信号,如果没有则返回 null'),
});

// 定义批量好友信息的 schema(数组)
const friendsArraySchema = z.array(friendSchema).describe('好友信息数组');

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

// 数据库连接配置
const connectionConfig = {
  host: 'localhost',
  port: 3306,
  user: 'root',
  password: 'admin',
  multipleStatements: true,
};

async function extractAndInsert(text) {
  const connection = await mysql.createConnection(connectionConfig);
  try {
    // 切换到 hello 数据库
    await connection.query(`USE hello;`);

    // 使用 AI 提取结构化信息
    console.log('🤔 正在从文本中提取信息...\n');
    const prompt = `请从以下文本中提取所有好友信息,文本中可能包含一个或多个人信息。请将每个人的信息分别提取出来。
${text}
要求:
1. 如果文本中包含多个人,请为每个人创建一个对象
2. 每个对象包含以下字段:
   - 姓名:提取文本中的人名
   - 性别:提取性别信息(男/女)
   - 出生日期:如果能找到具体日期最好,否则根据年龄描述估算(格式:YYYY-MM-DD)
   - 公司:提取公司名称
   - 职位:提取职位/头衔信息
   - 手机号:提取手机号码
   - 微信号:提取微信号
3. 如果某个字段在文本中找不到,请返回 null
4. 返回格式必须是一个数组,即使只有一个人也要放在数组中`;

    const results = await structuredModel.invoke(prompt);
    console.log(`✅ 提取到 ${results.length} 条结构化信息:`);
    console.log(JSON.stringify(results, null, 2));
    console.log('');

    if (results.length === 0) {
      console.log('⚠️ 没有提取到任何信息');
      return { count: 0, insertIds: [] };
    }

    // 批量插入数据库
    const insertSql = `
      INSERT INTO friends (
        name, gender, birth_date, company, title, phone, wechat
      ) VALUES ?;
    `;
    const values = results.map((result) => [
      result.name,
      result.gender,
      result.birth_date || null,
      result.company,
      result.title,
      result.phone,
      result.wechat,
    ]);

    const [insertResult] = await connection.query(insertSql, [values]);
    console.log(`✅ 成功批量插入 ${insertResult.affectedRows} 条数据`);
    console.log(`  插入的 ID 范围:${insertResult.insertId} - ${insertResult.insertId + insertResult.affectedRows - 1}`);

    return {
      count: insertResult.affectedRows,
      insertIds: Array.from({ length: insertResult.affectedRows }, (_, i) => insertResult.insertId + i),
    };
  } catch (err) {
    console.error('❌ 执行出错:', err);
    throw err;
  } finally {
    await connection.end();
  }
}

// 主函数
async function main() {
  // 示例文本(包含多个人的信息)
  const sampleText = `我最近认识了几个新朋友。第一个是张总,女的,看起来30出头,在腾讯做技术总监,手机13812345678。第二个是李工,男的,做后端开发,在阿里巴巴,微信号 likegong2024。还有一个是王老师,女博士,在北大当老师。`;

  console.log('📝 输入文本:');
  console.log(sampleText);
  console.log('');

  try {
    const result = await extractAndInsert(sampleText);
    console.log(`\n🎉 处理完成!成功插入 ${result.count} 条记录`);
    console.log(`  插入的 ID:${result.insertIds.join(', ')}`);
  } catch (error) {
    console.error('❌ 处理失败:', error.message);
    process.exit(1);
  }
}

main();

给一段无规则文本,用大模型提取结构化的信息:结构用 withStructuredOutput 指定,要求提取一个数组,数组里是好友对象的信息。然后把数组里的结构化数据批量插入数据库表。

跑一下:大模型把张总、李工、王老师的信息都提取出来了,并且批量插入了数据库。这样,我们就实现了智能录入的功能。

补充:JSON Schema 机制

前面讲 withStructuredOutput 底层是 tool、output parser,其实还有第三种特性:JSON Schema。创建 src/structured-json-schema.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
import { zodToJsonSchema } from "zod-to-json-schema";
import { HumanMessage, SystemMessage } from '@langchain/core/messages';

const scientistSchema = z.object({
  name: z.string().describe("科学家的全名"),
  birth_year: z.number().describe("出生年份"),
  field: z.string().describe("主要研究领域"),
  achievements: z.array(z.string()).describe("主要成就列表")
}).strict();

// 将 Zod 转换为原生的 JSON Schema 格式
const nativeJsonSchema = zodToJsonSchema(scientistSchema);

const model = new ChatOpenAI({
  modelName: "qwen-max",
  temperature: 0,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  modelKwargs: { // 通过 modelKwargs 传入原生参数
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "scientist_info",
        strict: true,
        schema: nativeJsonSchema // 这里的 nativeJsonSchema 就是转换后的对象
      }
    }
  }
});

async function testNativeJsonSchema() {
  console.log("🧪 测试原生 JSON Schema 模式...\n");
  const res = await model.invoke([
    new SystemMessage("你是一个信息提取助手,请直接返回 JSON 数据。"),
    new HumanMessage("介绍一下杨振宁")
  ]);
  console.log("\n✅ 收到响应(纯净 JSON):");
  console.log(res.content);

  const data = JSON.parse(res.content);
  console.log("\n📋 解析后的对象:");
  console.log(data);
}

testNativeJsonSchema().catch(console.error);

指定大模型的输出格式为 json_schema,它就会按照这个格式输出。安装依赖:

bash
pnpm install zod-to-json-schema

JSON Schema 就和 tool 的 args 一样,都是大模型层面支持的,会保证按照这个格式来返回,如果格式不对,会在模型层面重新生成正确的返回。

也就是说,withStructuredOutput 底层是 tool、json schema、output parser 这三者。当然,平时开发用 withStructuredOutput 就可以了,这个 API 会根据模型自动选择对应的实现。

实战二:流式版 mini cursor

还记得之前做的 mini-cursor 么?当时等了好久,大概一分钟才显示写入成功。其实这时候一直在生成代码内容,只不过我们没做流式打印,只能干等。我们学完流式 + output parser 之后,就可以优化了。

先想想之前不用流式是什么流程:传入 SystemMessage 和 HumanMessage,调用大模型之后返回 AIMessage;把这个 AIMessage 加入 memory;之后根据 AIMessage 中的 tool_calls 信息调用 tool,执行结果封装成 ToolMessage 放入 memory;直到不再返回带 tool_calls 信息的 AIMessage,就代表循环结束。

那如果改成流式返回的话,难点在哪呢?

  • 难点一:返回的 AIMessage 是 chunk。我们要把 AIMessageChunk 拼接成完整的 AIMessage 才能放入 Memory 再次调用大模型——用它的 concat 方法即可。流式返回一个个 AIMessageChunk,调用 concat 方法合并一下,流式结束就拿到了完整的 AIMessage。
  • 难点二:现在返回的是 tool_call_chunks 怎么办?前面我们用 JsonOutputToolsParser 把它转成了 tool_calls。原始的 stream 里的 tool_call_chunks 是片段信息,并不是一个合法的 json;而用了 JsonOutputToolsParser 之后,拿到的就是积累的片段拼起来的 json 了。所以我们不用自己解析这些片段,直接用 JsonOutputToolsParser 解析之后取某个属性就行。

按照这个思路来实现新版 mini cursor,创建 src/test/mini-cursor.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { JsonOutputToolsParser } from '@langchain/core/output_parsers/openai_tools';
import { executeCommandTool, listDirectoryTool, readFileTool, writeFileTool } from './all-tools.mjs';
import chalk from 'chalk';

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

const tools = [
  readFileTool,
  writeFileTool,
  executeCommandTool,
  listDirectoryTool,
];

// 绑定工具到模型
const modelWithTools = model.bindTools(tools);

// Agent 执行函数
async function runAgentWithTools(query, maxIterations = 30) {
  const history = new InMemoryChatMessageHistory();
  await history.addMessage(new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
工具:
1. read_file: 读取文件
2. write_file: 写入文件
3. execute_command: 执行命令(支持 workingDirectory 参数)
4. list_directory: 列出目录
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
重要规则 - write_file:
- 当写入 React 组件文件(如 App.tsx)时,如果存在对应的 CSS 文件(如 App.css),在其他 import 语句后加上 import 该 CSS 文件
`));
  await history.addMessage(new HumanMessage(query));

  for (let i = 0; i < maxIterations; i++) {
    console.log(chalk.bgGreen(`⏳ 正在等待 AI 思考...`));
    // 获取当前消息历史
    const messages = await history.getMessages();
    const rawStream = await modelWithTools.stream(messages);

    // 准备一个空的容器来拼接完整的 AIMessage
    let fullAIMessage = null;
    // 准备一个 tool_call_chunks 的 JSON 增量解析器
    const toolParser = new JsonOutputToolsParser();
    // 记录每个工具调用已打印的长度(用 id 或 filePath 作为 key)
    const printedLengths = new Map();

    console.log(chalk.bgBlue(`\n🚀 Agent 开始思考并生成流...\n`));
    for await (const chunk of rawStream) {
      // 这里的 chunk 是 AIMessageChunk,把它拼接起来
      fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;

      let parsedTools = null;
      try {
        parsedTools = await toolParser.parseResult([{ message: fullAIMessage }]);
      } catch (e) {
        // 解析失败说明 JSON 还不完整,忽略错误继续累积
      }

      if (parsedTools && parsedTools.length > 0) {
        for (const toolCall of parsedTools) {
          if (toolCall.type === 'write_file' && toolCall.args?.content) {
            const toolCallId = toolCall.id || toolCall.args.filePath || 'default';
            const currentContent = String(toolCall.args.content);
            const previousLength = printedLengths.get(toolCallId);

            if (previousLength === undefined) {
              printedLengths.set(toolCallId, 0);
              console.log(
                chalk.bgBlue(`\n[工具调用] write_file("${toolCall.args.filePath}") - 正在生成内容...`)
              );
            }
            if (currentContent.length > previousLength) {
              const newContent = currentContent.slice(previousLength);
              process.stdout.write(newContent);
              printedLengths.set(toolCallId, currentContent.length);
            }
          }
        }
      } else {
        // 当前还没有解析出工具调用时,如果有文本内容就直接输出
        if (chunk.content) {
          process.stdout.write(
            typeof chunk.content === 'string'
              ? chunk.content
              : JSON.stringify(chunk.content),
          );
        }
      }
    }

    // 此时 fullAIMessage 已经完美还原,直接存入 history
    await history.addMessage(fullAIMessage);
    console.log(chalk.green('\n✅ 消息已完整存入历史'));

    // 检查是否有工具调用
    if (!fullAIMessage.tool_calls || fullAIMessage.tool_calls.length === 0) {
      console.log(`\n✨ AI 最终回复:\n${fullAIMessage.content}\n`);
      return fullAIMessage.content;
    }

    // 执行工具调用
    for (const toolCall of fullAIMessage.tool_calls) {
      const foundTool = tools.find((t) => t.name === toolCall.name);
      if (foundTool) {
        const toolResult = await foundTool.invoke(toolCall.args);
        await history.addMessage(
          new ToolMessage({
            content: toolResult,
            tool_call_id: toolCall.id,
          }),
        );
      }
    }
  }

  const finalMessages = await history.getMessages();
  return finalMessages[finalMessages.length - 1].content;
}

const case1 = `创建一个功能丰富的 React TodoList 应用:
1. 创建项目: echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList:
 - 添加、删除、编辑、标记完成
 - 分类筛选(全部 / 进行中 / 已完成)
 - 统计信息显示
 - localStorage 数据持久化
3. 添加复杂样式:
 - 渐变背景(蓝到紫)
 - 卡片阴影、圆角
 - 悬停效果
4. 添加动画:
 - 添加 / 删除时的过渡动画
 - 使用 CSS transitions
5. 列出目录确认
注意:使用 pnpm,功能要完整,样式要美观,要有动画效果
去掉 main.tsx 里的 index.css 导入
之后在 react-todo-app 项目中:
1. 使用 pnpm install 安装依赖
2. 使用 pnpm run dev 启动服务器
`;

try {
  await runAgentWithTools(case1);
} catch (error) {
  console.error(`\n❌ 错误: ${error.message}\n`);
}

其实你理解了刚才的两个点——拼接 AIMessageChunk 成完整 AIMessage、用 JsonOutputToolsParser 来解析 tool_call_chunks——就很容易理解上面的代码。

  • 我们用 InMemoryChatMessageHistory 来管理 memory,不是直接用 messages 数组了
  • 拼接:fullAIMessage = fullAIMessage.concat(chunk),当 chunk 遍历完,自然也就拼好了,这时候就放入 memory
  • 解析:调用 toolParser.parseResult([{ message: fullAIMessage }]),传入我们 concat 好的部分 AIMessage
  • 增量打印:加了一个 Map 来记录已经打印过的长度,下次 slice 一下继续打印后面的。第一次打印工具调用的日志,在 Map 里记录长度为 0,后续每次打印截取掉这个长度之后的 args 信息,这样就能实现增量打印的效果

用到的 all-tools.mjs 从之前 tool-test 里复制,再安装下依赖:

bash
pnpm install chalk

跑一下:现在的 mini cursor 就能流式打印生成的代码了。流式打印生成的代码,等完整之后再一次写入即可——写入的内容从拼接完整的 AIMessage 取 tool_calls 的参数信息;前面用 JsonOutputToolsParser 解析 tool_call_chunks 拼起来的 tool_calls 的 args 只是流式打印用,调用工具的时候还是直接从 AIMessage 取完整的。

虽然只是加了一个流式,但是代码改动还挺多的:之前是完整的 AIMessage 和 tool_calls,现在需要自己对 AIMessageChunk 做 concat,以及用 JsonOutputToolsParser 解析 tool_call_chunks。

常见问题

MySQL 创建表报 SQL 语法错误? 检查 multipleStatements: true 是否设置,以及 SQL 语句的完整性。如果 Docker 搜索 MySQL 镜像结果为 0,可以命令行 docker pull mysql 或科学上网。

大模型把"28 岁"推成"1996-01-01"? 大模型训练有截止时间,它不知道当前时间。可以写动态提示词("当前时间是 ${Date.now()}"),或者写一个 tool 来计算生日。

有时第一轮或第二轮就结束了? 加一个 content 跟工具调用都为空时的判断,强制再触发一轮。

国产模型(如 deepseek)不支持 json_schema? 需要:1. 关闭思考模式;2. 用 z.object({ friends: z.array(friendSchema) }) 包裹;3. 设置 method: "functionCalling"。如果用 jsonObject 模式,需要在 prompt 里告诉 LLM key 的英文(否则会输出中文 key)。

zodToJsonSchema 报错或用不了? 新版本可以直接用 scientistSchema.toJSONSchema(),不需要额外装 zod-to-json-schema 包。

为什么不直接打印 tool_call_chunks 要绕一圈 JsonOutputToolsParser? 因为 tool_call_chunks 是不完整的 json 片段,你不知道该打印哪部分;比如参数的 json 有 4 个属性,你要打印的是第三个属性,直接打印 chunk 做不到,正则也不靠谱。parser 才能拿到完整结构。

流式过程中工具参数能直接用吗? 流式过程中 parser 解析出的 args 可能还不完整(同一个 tool 在 parseResult 时会经历成功→失败→成功多次循环),调用工具时还是从完整拼接后的 AIMessage 取 tool_calls 参数。

总结

这节我们做了大模型输出控制的两个小实战:

  • 智能录入:常见需求,调用大模型对一段文本做解析,返回结构化的数据,一般用 model.withStructuredOutput,之后存入数据库即可
  • 流式版 mini cursor:主要要流式打印 tool 的参数,需要做好 AIMessageChunk 的 concat,以及用 JsonOutputToolsParser 做 tool_call_chunks 的解析,之后增量打印

此外,我们还补充学习了 withStructuredOutput 底层的另一个 JSON Schema 机制。当然,平时做结构化直接用 withStructuredOutput 就行,底层会自动根据模型来选择 tool、json schema 或者 output parser。

常见的输出控制需求就这两种:结构化输出、流式输出 + tool 参数解析

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