airi 中的 xsAI 结构化输出:从 generateObject、streamObject 到 tool() 与 rawTool 的实践
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
本篇以 airi 仓库中.agents/skills/xsai/references/structured-output.md参考文档为核心,系统讲解 xsAI(一个极小型 OpenAI 兼容 LLM 运行时)的结构化输出体系:generateObject/streamObject两种 API、基于xsschema的 schema 库选型、tool()与rawTool()两个工具助手,以及推荐规则;并结合 airi 仓库中 Spark Notify 与 Web 搜索工具的真实源码,展示这些 API 在生产代码中如何与 Zod、JSON Schema 转换和 provider 归一化协作落地。读完本文,你可以为任意 OpenAI 兼容端点写出带校验的结构化输出、流式对象解析与类型安全工具,并理解 airi 中“schema 即契约”的实现细节。
一、结构化输出 API 总览:generateObject 与 streamObject
structured-output.md开篇即给出范围声明:该参考文档服务于generateObject、streamObject、tool()、rawTool()以及 schema 选型指导。文档将两个核心 API 做了清晰分工:
generateObject:一元(unary)结构化输出,返回经过 schema 校验的最终对象。适用于“等模型一次性吐完、拿到可信结果再往下走”的场景。streamObject:增量式结构化输出,边生成边产出部分对象。适用于 UI 需要逐步渲染、或工作流希望尽早消费中间结果的场景。
两者的共同底座是xsschema:schema 的转换统一经由xsschema完成。这正是理解 xsAI 结构化输出的关键——它不直接绑定某个 schema 库,而是通过 Standard Schema 风格(一种跨库的标准接口)接入各家类型库,再统一转换为 OpenAI 兼容端点能消费的 JSON Schema。
最小可运行示例
airi 仓库内的姊妹文档 recipes.md 给出了规范的最小示例。结构化输出的标准起点如下(Valibot 示例,项目需额外安装@valibot/to-json-schema):
import { env } from 'node:process' import { generateObject } from '@xsai/generate-object' import * as v from 'valibot' const { object } = await generateObject({ apiKey: env.OPENAI_API_KEY!, baseURL: 'https://api.openai.com/v1/', messages: [ { content: 'Extract the event information.', role: 'system', }, { content: 'Alice and Bob are going to a science fair on Friday.', role: 'user', }, ], model: 'gpt-4o', schema: v.object({ date: v.string(), name: v.string(), participants: v.array(v.string()), }), })从示例可以确认三个实践约束(与 SKILL.md 中的“Key constraints”一致):
baseURL与model在实际调用中通常必填,必须显式写出;apiKey视 provider 而定:托管端点需要(在 Node.js 中推荐从process.env读取,在浏览器中推荐从localStorage读取),本地/代理端点可能不需要;示例中一律不硬编码密钥;schema传入的是 Standard Schema 风格的类型定义(此处为 Valibot 的v.object),由xsschema负责转成 JSON Schema。
为什么优先 generateObject 而非自由 JSON
参考文档的“Recommendation rules”第一条就是:优先使用generateObject,而不是让模型输出自由格式 JSON。理由很直接:自由 JSON 只能靠调用方事后JSON.parse再手工校验,字段缺失、类型漂移、幻觉字段都无解;而generateObject的输出在返回前已经过 schema 校验,调用方拿到的是可断言类型的对象。这也是 airi 内部代码的一致选择——仓库里几乎看不到“让模型吐 JSON 字符串再解析”的旧式写法,取而代之的是 schema 驱动的rawTool+xsschema校验路径(见第四节)。
二、generateObject的关键选项
文档列出了generateObject的五个重要选项:
| 选项 | 作用 |
|---|---|
schema | 必填。目标对象的类型定义(Standard Schema 风格),同时决定校验规则 |
schemaName | 为该对象 schema 命名,便于 provider 端标识与调试 |
schemaDescription | 描述 schema 的整体用途,帮助模型理解要产出什么 |
strict | 严格模式。开启后模型输出中的额外字段会被拒绝/裁剪,保证对象形状与 schema 完全一致 |
output: 'array'(可选) | 将输出形态从“单个对象”切换为“数组”,适用于一次抽取多条同类记录(如多个人名、多条事件) |
strict与output两个选项的组合空间覆盖了绝大多数抽取任务:单条记录用默认对象模式;“从一段文本中抽取 N 条同构记录”时用output: 'array';对下游字段消费方敏感的场景(如直接写入数据库或触发调度)则打开strict防止模型夹带未知字段。这些选项最终都体现在发往 OpenAI 兼容端点的请求里,由 provider 侧的 structured output / JSON Schema 约束机制保证输出形状。
三、streamObject:流式结构化输出与两种结果形态
当 UI 或工作流受益于“部分结构化输出”时,参考文档推荐切换到streamObject。文档明确指出两种模式对应的结果形态:
- 对象模式(object mode):消费
partialObjectStream,每个 tick 拿到一个不断趋近完整的部分对象; - 数组模式(array mode):配合
output: 'array'使用elementStream,逐元素产出,适合“一条条卡片/条目”式的实时渲染。
recipes.md 的“Streaming structured output”给出了对象模式的标准写法:
import { env } from 'node:process' import { streamObject } from '@xsai/stream-object' import * as v from 'valibot' const { partialObjectStream } = await streamObject({ apiKey: env.OPENAI_API_KEY!, baseURL: 'https://api.openai.com/v1/', messages: [ { content: 'Extract the event information.', role: 'system', }, { content: 'Alice and Bob are going to a science fair on Friday.', role: 'user', }, ], model: 'gpt-4o', schema: v.object({ date: v.string(), name: v.string(), participants: v.array(v.string()), }), }) for await (const partialObject of partialObjectStream) { console.log(partialObject) }注意这里的一个容易踩坑的细节:streamObject本身是 async 的,调用处必须await才能拿到partialObjectStream。原因在参考文档中有一句关键解释——“schema 转换发生在文本流开始之前”:xsAI 需要先把 Standard Schema 经xsschema转换成 JSON Schema、再把带 schema 约束的请求发出去,流才能启动。这与streamText(同步返回、调用方异步消费textStream/fullStream)的行为形成对照,调用方如果忘记await会拿不到任何流。
在 airi 这类虚拟主播/桌面伴侣应用中,数组模式(elementStream)尤其有价值:例如让模型逐条生成“日程卡片”“提醒条目”时,前端可以边生成边追加,而不是等待整个数组闭合后才一次性呈现。
四、Schema 选型:Standard Schema 生态与 xsschema
参考文档明确 xsAI 通过xsschema支持 Standard Schema 风格库,官方列出的四家为:
- Zod
- Valibot
- ArkType
- Effect
并且给出了两条重要的依赖提示——某些 schema 厂商需要额外的 JSON Schema 转换包才能接入:
- Zod v3:
zod-to-json-schema - Valibot:
@valibot/to-json-schema
“Use these when the user wants typed structured output” 这句话点出了设计意图:只要用户要“有类型的结构化输出”,就应沿用项目已有的类型库,让 xsAI 通过xsschema适配层消化库间差异,而不是强制统一成某一家。
仓库实证:airi 里的实际选型
从源码结构看,airi 选择了Zod +xsschema的组合。packages/core-agent/package.json 与 apps/stage-tamagotchi/package.json、integrations/telegram-bot/package.json 等均声明了@xsai/shared-chat、@xsai/tool(通过 pnpm catalog 统一版本),而xsschema与zod出现在这些包的实际 import 中。两处典型用法:
1)rawTool+toJsonSchema:把 Zod 定义编译成 provider 可消费的 JSON Schema。packages/stage-ui/src/tools/web-search.ts 是教科书式的例子:
// packages/stage-ui/src/tools/web-search.ts(节选) import { rawTool } from '@xsai/tool' import { toJsonSchema } from 'xsschema' import { z } from 'zod/v4' const webSearchParameters = z.object({ query: z.string().min(2).max(400).describe('The search query. Be specific; this is sent to a web search engine.'), max_results: z.union([z.number().int().min(MIN_MAX_RESULTS).max(MAX_MAX_RESULTS), z.null()]).describe('How many results to return (1-10), or null for the default of 5.'), time_range: z.union([z.enum(['day', 'week', 'month', 'year']), z.null()]).describe('Restrict results to a recent time window when freshness matters, or null for no restriction.'), include_domains: z.union([z.array(z.string()).max(10), z.null()]).describe('Only return results from these domains, or null.'), exclude_domains: z.union([z.array(z.string()).max(10), z.null()]).describe('Never return results from these domains, or null.'), }) export async function createWebSearchTools(options: { apiKey: string, timeoutMs?: number }): Promise<Tool[]> { // Keep the generated JSON Schema provider-neutral. const parameters = await toJsonSchema(webSearchParameters) return [ rawTool({ name: 'web_search', description: 'Search the web for current or unfamiliar information and return a list of results with source URLs.', parameters, execute: async (rawInput, { abortSignal }: ToolExecuteOptions) => { // Keep the runtime range check because rawTool does not validate input. const input = rawInput as WebSearchInput const maxResults = Math.min(Math.max(MIN_MAX_RESULTS, Math.trunc(input.max_results ?? DEFAULT_MAX_RESULTS)), MAX_MAX_RESULTS) // ... }, }), ] }这里能看到两条与参考文档呼应、且文档本身未展开的工程细节:
parameters是await出来的:再次印证“schema 转换是前置异步步骤”这一行为特征,rawTool的构造因此通常发生在 async 工厂函数里;rawTool不做入参校验:源码注释明说 “Keep the runtime range check because rawTool does not validate input”,所以execute内部手动做了Math.min/Math.max的边界钳制。这与参考文档把rawTool()定位为“已有 JSON Schema / 零 schema 库耦合时使用”是一致的——你把类型库的工作接走了,运行时防御也得自己补。
2)rawTool+toJsonSchema+validate:工具执行时的二次校验。packages/core-agent/src/agents/spark-notify/tools.ts 中,builtIn_sparkCommand工具把模型可能多次调用的“子代理指令”用 Zod 描述(sparkNotifyCommandSchema),执行时走 Standard Schema 的validate做运行时验证,失败则把错误以AIRI System: Error - invalid spark_command parameters: ...的形式回传给模型,让其自我修正:
// packages/core-agent/src/agents/spark-notify/tools.ts(节选) tools.push(rawTool({ name, description: 'Issue a spark:command to sub-agents. You can call this tool multiple times.', parameters: normalizeNullableAnyOf(await toJsonSchema(sparkNotifyCommandSchema) as any), execute: async (rawPayload, context) => { try { const payload = rawPayload as z.infer<typeof sparkNotifyCommandSchema> const validated = await validate(sparkNotifyCommandSchema, payload) options.onCommands(validated.commands.map(normalizeSparkNotifyCommand)) // ... } catch (error) { // ... return `AIRI System: Error - invalid spark_command parameters: ${errorMessageFrom(error)}` } return 'AIRI System: Acknowledged, command fired.' }, }))这段代码展示了generateObject“校验后再消费”理念在工具调用链路里的等价物:模型输出 → schema 校验 → 校验通过才进入下游调度,不通过则把结构化错误信息喂回模型。
五、tool() 与 rawTool():两种工具注册路径
参考文档对两个工具助手的分工只有一句话,但选型含义明确:
tool():配合 Standard Schema 库(Zod、Valibot 等)使用;rawTool():配合现成的 JSON Schema 使用。
推荐规则进一步收敛了选择:“用户项目里已有 schema 库时优先tool();用户手里已经有 JSON Schema、或希望与 schema 库零耦合时改用rawTool()。”
两者的行为差异可以在 airi 源码中得到印证:
tool()路径下,parameters传 Standard Schema,入参在执行前可被库本身校验,execute收到的是已验证的对象;rawTool()路径下,parameters是裸 JSON Schema(通常由toJsonSchema从 Zod 生成),xsAI 只负责把它挂到请求的tools字段上,入参校验与类型收窄由调用方完成(如上节web-search.ts中的手动钳制与as WebSearchInput断言)。
一个值得注意的命名细节:web_search工具刻意使用 snake_case 且不加builtIn_前缀,源码注释解释为它是“面向用户能力的、模型可识别的名字”,区别于常驻的基础设施工具(builtIn_前缀的 MCP/调试/spark 工具)。这提示在使用rawTool自定义工具时,工具名是模型可见的契约,应与描述、参数命名一起设计。
六、provider 侧的 JSON Schema 归一化:文档没写但源码里的必修课
参考文档声明 xsAI 是 OpenAI 兼容优先的,但“兼容”在 schema 层面并不总是一帆风顺——不同兼容端点对 JSON Schema 形态的容忍度不同。airi 仓库里沉淀了两处针对这一问题的归一化工具,恰好是rawTool+toJsonSchema组合落地的最后一公里:
1) 可空标量的 anyOf 折叠。packages/core-agent/src/agents/spark-notify/schema.ts 中的normalizeNullableAnyOf说明了问题:xsschema对可空联合(如z.union([z.string(), z.null()]))产出的是{ anyOf: [{ type: 'string' }, { type: 'null' }] }形态,而部分 OpenAI 兼容校验器拒绝该形态、只接受type: ['string', 'null']。该函数递归遍历properties/items/anyOf/oneOf,把“纯标量 anyOf”折叠为 type 数组:
// Before: { anyOf: [{ type: 'string' }, { type: 'null' }] } // After: { type: ['string', 'null'] }2) provider 适配层级的折叠 + required 修正。packages/stage-ui/src/libs/providers/tool-schema.ts 的collapseToolSchemaPrimitiveAnyOf做了更完整的版本:除折叠 anyOf 外,还会把 number/integer 分支上的minimum/maximum/multipleOf等约束合并到目标节点,并在properties变化后清理失效的required键(required为空时整键删除)。函数注释明确其使用前提:“只在确认某个 provider 拒绝了标准 anyOf 形态后,才在该 provider 适配器里使用”——即保持生成出的 JSON Schema 默认 provider-neutral,按需降级。
从源码结构看,这套分层对应一个清晰的职责划分:xsschema负责“类型库 → JSON Schema”,xsAI 负责“JSON Schema → OpenAI 兼容请求”,而 airi 的 provider 适配器负责“不同兼容端点的方言差异”。理解这条链路后,遇到某端点 400 拒绝工具 schema 时,排查顺序就是:先看 Zod 定义是否过严,再看是否缺了 anyOf 折叠,最后才怀疑网络或鉴权。
七、选型决策清单
把参考文档的“Recommendation rules”与 SKILL.md 的“API selection rules”合并,可以得到一张覆盖结构化输出与工具调用的完整决策表:
| 需求 | 首选 API | 依据 |
|---|---|---|
| 需要一个最终校验过的对象 | generateObject | 优先于让模型产出自由 JSON |
| UI/工作流要逐步拿到结构化中间结果 | streamObject | 对象模式用partialObjectStream,数组模式用elementStream |
| 项目已有 Zod/Valibot 等 schema 库 | tool() | Standard Schema 路径,入参自带库级校验 |
| 已有 JSON Schema,或要求零 schema 库耦合 | rawTool() | 注意自行补运行时校验(rawTool 不验证入参) |
| 流式文本/工具事件/轻量 agent 循环 | streamText+stopWhen | 与结构化输出互补,非替代 |
配套的约束提醒(均出自 SKILL.md 的 Key constraints):generateObject()、streamObject()、tool()均依赖xsschema,个别 schema 厂商需要额外安装 JSON Schema 转换包(Zod v3 →zod-to-json-schema,Valibot →@valibot/to-json-schema);xsAI 刻意保持极小,若只为结构化输出一个能力,应选用粒化的@xsai/generate-object/@xsai/stream-object/@xsai/tool包,而不是伞形xsai包。
小结
xsAI 的结构化输出体系可以用一条主线串起来:类型库(Zod/Valibot/ArkType/Effect)→xsschema转换 →generateObject/streamObject/tool()/rawTool()四类 API → OpenAI 兼容端点。generateObject解决“可信的最终对象”,streamObject解决“增量可见的部分对象”,tool()与rawTool()分别覆盖“有 schema 库”与“裸 JSON Schema”两条工具注册路径。airi 仓库中的 web-search.ts、spark-notify/tools.ts 与 tool-schema.ts 则补上了参考文档留白的另一半:异步 schema 转换的实际调用位置、rawTool不校验入参时的运行时防御,以及跨兼容端点的 JSON Schema 归一化策略。按这条主线选型与排障,即可在 airi 或任意 OpenAI 兼容栈上稳定落地带校验的结构化输出。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考