@mariozechner/pi-ai
统一的 LLM API,支持自动模型发现、提供商配置、Token 和成本追踪,以及简单的上下文持久化和会话中途切换到其他模型。
注意:本库仅包含支持工具调用(函数调用)的模型,因为这对智能体工作流至关重要。
Quick Start
Step1: 选择供应商和模型,定义Tool,构造模型对话上下文。
import { Type, getModel, stream, complete, Context, Tool, StringEnum } from '@mariozechner/pi-ai';
// 完全类型化,支持提供商和模型的自动补全
const model = getModel('openai', 'gpt-4o-mini');
// 使用 TypeBox schema 定义工具,实现类型安全和验证
const tools: Tool[] = [{
name: 'get_time',
description: 'Get the current time',
parameters: Type.Object({
timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
})
}];
// 构建对话上下文(易于序列化,可在模型间传递)
const context: Context = {
systemPrompt: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'What time is it?' }],
tools
};
Step2-1:流式输出
// 方式 1:流式传输,包含所有事件类型
const s = stream(model, context);
for await (const event of s) {
switch (event.type) {
case 'start':
console.log(`Starting with ${event.partial.model}`);
break;
case 'text_start':
console.log('\n[Text started]');
break;
case 'text_delta':
process.stdout.write(event.delta);
break;
case 'text_end':
console.log('\n[Text ended]');
break;
case 'thinking_start':
console.log('[Model is thinking...]');
break;
case 'thinking_delta':
process.stdout.write(event.delta);
break;
case 'thinking_end':
console.log('[Thinking complete]');
break;
case 'toolcall_start':
console.log(`\n[Tool call started: index ${event.contentIndex}]`);
break;
case 'toolcall_delta':
// Partial tool arguments are being streamed
const partialCall = event.partial.content[event.contentIndex];
if (partialCall.type === 'toolCall') {
console.log(`[Streaming args for ${partialCall.name}]`);
}
break;
case 'toolcall_end':
console.log(`\nTool called: ${event.toolCall.name}`);
console.log(`Arguments: ${JSON.stringify(event.toolCall.arguments)}`);
break;
case 'done':
console.log(`\nFinished: ${event.reason}`);
break;
case 'error':
console.error(`Error: ${event.error}`);
break;
}
}
// 获取流式传输后的最终消息,添加到上下文中
const finalMessage = await s.result();
context.messages.push(finalMessage);
Step2-2:非流式输出
// 方式 2:只获取最终响应
const response = await complete(model, context);
for (const block of response.content) {
if (block.type === 'text') {
console.log(block.text);
} else if (block.type === 'toolCall') {
console.log(`Tool: ${block.name}(${JSON.stringify(block.arguments)})`);
}
}
Step3:处理工具调用
// Get the final message after streaming, add it to the context
const finalMessage = await s.result();
context.messages.push(finalMessage);
// 处理工具调用(如果有)
const toolCalls = finalMessage.content.filter(b => b.type === 'toolCall');
for (const call of toolCalls) {
// 执行工具调用
const result = call.name === 'get_time'
? new Date().toLocaleString('en-US', {
timeZone: call.arguments.timezone || 'UTC',
dateStyle: 'full',
timeStyle: 'long'
})
: 'Unknown tool';
// 将工具结果添加到上下文(支持文本和图像)
context.messages.push({
role: 'toolResult',
toolCallId: call.id,
toolName: call.name,
content: [{ type: 'text', text: result }],
isError: false,
timestamp: Date.now()
});
}
// 如果有工具调用则继续
if (toolCalls.length > 0) {
const continuation = await complete(model, context);
context.messages.push(continuation);
console.log('After tool execution:', continuation.content);
}
console.log(`Total tokens: ${finalMessage.usage.input} in, ${finalMessage.usage.output} out`);
console.log(`Cost: $${finalMessage.usage.cost.total.toFixed(4)}`);
工具
工具使 LLM 能够与外部系统交互。本库使用 TypeBox schema 进行类型安全的工具定义,并使用 AJV 进行自动验证。TypeBox schema 可以作为普通 JSON 进行序列化和反序列化,非常适合分布式系统。
工具定义
import { Type, Tool, StringEnum } from '@mariozechner/pi-ai';
// 使用 TypeBox 定义工具参数
const weatherTool: Tool = {
name: 'get_weather',
description: '获取指定地点的当前天气',
parameters: Type.Object({
location: Type.String({ description: '城市名称或坐标' }),
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
})
};
// 注意:为了与 Google API 兼容,请使用 StringEnum 辅助函数而不是 Type.Enum
// Type.Enum 生成 Google 不支持的 anyOf/const 模式
const bookMeetingTool: Tool = {
name: 'book_meeting',
description: '安排会议',
parameters: Type.Object({
title: Type.String({ minLength: 1 }),
startTime: Type.String({ format: 'date-time' }),
endTime: Type.String({ format: 'date-time' }),
attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
})
};
处理工具调用
工具结果使用内容块,可以包含文本和图像:
import { readFileSync } from 'fs';
const context: Context = {
messages: [{ role: 'user', content: '伦敦的天气怎么样?' }],
tools: [weatherTool]
};
const response = await complete(model, context);
// 检查响应中的工具调用
for (const block of response.content) {
if (block.type === 'toolCall') {
// 使用参数执行你的工具
// 参见"验证工具参数"章节进行验证
const result = await executeWeatherApi(block.arguments);
// 添加带有文本内容的工具结果
context.messages.push({
role: 'toolResult',
toolCallId: block.id,
toolName: block.name,
content: [{ type: 'text', text: JSON.stringify(result) }],
isError: false,
timestamp: Date.now()
});
}
}
// 工具结果也可以包含图像(对于支持视觉的模型)
const imageBuffer = readFileSync('chart.png');
context.messages.push({
role: 'toolResult',
toolCallId: 'tool_xyz',
toolName: 'generate_chart',
content: [
{ type: 'text', text: '生成的显示温度趋势的图表' },
{ type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
],
isError: false,
timestamp: Date.now()
});
工具调用流式传输
在流式传输期间,工具调用参数会在到达时逐步解析。这使得在完整参数可用之前就能进行实时 UI 更新:
const s = stream(model, context);
for await (const event of s) {
if (event.type === 'toolcall_delta') {
const toolCall = event.partial.content[event.contentIndex];
// toolCall.arguments 在流式传输期间包含部分解析的 JSON
// 这允许进行渐进式 UI 更新
if (toolCall.type === 'toolCall' && toolCall.arguments) {
// 要防御性编程:arguments 可能不完整
// 示例:即使内容未完成也显示正在写入的文件路径
if (toolCall.name === 'write_file' && toolCall.arguments.path) {
console.log(`正在写入:${toolCall.arguments.path}`);
// 内容可能是部分的或缺失的
if (toolCall.arguments.content) {
console.log(`内容预览:${toolCall.arguments.content.substring(0, 100)}...`);
}
}
}
}
if (event.type === 'toolcall_end') {
// 此时 toolCall.arguments 是完整的(但尚未验证)
const toolCall = event.toolCall;
console.log(`工具完成:${toolCall.name}`, toolCall.arguments);
}
}
验证工具调用参数
当使用 agentLoop 时,工具参数会在执行前根据你的 TypeBox schema 自动验证。如果验证失败,错误会作为工具结果返回给模型,允许其重试。
当你使用 stream() 或 complete() 实现自己的工具执行循环时,使用 validateToolCall 在将参数传递给工具之前进行验证:
import { stream, validateToolCall, Tool } from '@mariozechner/pi-ai';
const tools: Tool[] = [weatherTool, calculatorTool];
const s = stream(model, { messages, tools });
for await (const event of s) {
if (event.type === 'toolcall_end') {
const toolCall = event.toolCall;
try {
// 根据 tool 的 schema 验证参数(无效时抛出异常)
const validatedArgs = validateToolCall(tools, toolCall);
const result = await executeMyTool(toolCall.name, validatedArgs);
// ... 将工具结果添加到上下文
} catch (error) {
// 验证失败 - 将错误作为工具结果返回,让模型可以重试
context.messages.push({
role: 'toolResult',
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: 'text', text: error.message }],
isError: true,
timestamp: Date.now()
});
}
}
}
完整事件参考
所有流式事件:
| 事件类型 | 描述 | 关键属性 |
|---|---|---|
start |
流开始 | partial:初始助手消息结构 |
text_start |
文本块开始 | contentIndex:在内容数组中的位置 |
text_delta |
接收到文本片段 | delta:新文本,contentIndex:位置 |
text_end |
文本块完成 | content:完整文本,contentIndex:位置 |
thinking_start |
思考块开始 | contentIndex:在内容数组中的位置 |
thinking_delta |
接收到思考片段 | delta:新文本,contentIndex:位置 |
thinking_end |
思考块完成 | content:完整思考内容,contentIndex:位置 |
toolcall_start |
工具调用开始 | contentIndex:在内容数组中的位置 |
toolcall_delta |
工具参数流式传输 | delta:JSON 片段,partial.content[contentIndex].arguments:部分解析的参数 |
toolcall_end |
工具调用完成 | toolCall:完整的已验证工具调用,包含 id、name、arguments |
done |
流完成 | reason:停止原因(“stop”、“length”、“toolUse”),message:最终助手消息 |
error |
发生错误 | reason:错误类型(“error” 或 “aborted”),error:包含部分内容的 AssistantMessage |
图像输入
具有视觉能力的模型可以处理图像。你可以通过 input 属性检查模型是否支持图像。如果你向非视觉模型传递图像,它们会被静默忽略。
import { readFileSync } from 'fs';
import { getModel, complete } from '@mariozechner/pi-ai';
const model = getModel('openai', 'gpt-4o-mini');
// 检查模型是否支持图像
if (model.input.includes('image')) {
console.log('模型支持视觉');
}
const imageBuffer = readFileSync('image.png');
const base64Image = imageBuffer.toString('base64');
const response = await complete(model, {
messages: [{
role: 'user',
content: [
{ type: 'text', text: '这张图片里有什么?' },
{ type: 'image', data: base64Image, mimeType: 'image/png' }
]
}]
});
// 访问响应
for (const block of response.content) {
if (block.type === 'text') {
console.log(block.text);
}
}
思考/推理
许多模型支持思考/推理能力,可以展示其内部思维过程。你可以通过 reasoning 属性检查模型是否支持推理。如果你向非推理模型传递推理选项,它们会被静默忽略。
统一接口
import { getModel, streamSimple, completeSimple } from '@mariozechner/pi-ai';
// 跨提供商的许多模型都支持思考/推理
const model = getModel('anthropic', 'claude-sonnet-4-20250514');
// 或 getModel('openai', 'gpt-5-mini');
// 或 getModel('google', 'gemini-2.5-flash');
// 或 getModel('xai', 'grok-code-fast-1');
// 或 getModel('groq', 'openai/gpt-oss-20b');
// 或 getModel('cerebras', 'gpt-oss-120b');
// 或 getModel('openrouter', 'z-ai/glm-4.5v');
// 检查模型是否支持推理
if (model.reasoning) {
console.log('模型支持推理/思考');
}
// 使用简化的推理选项
const response = await completeSimple(model, {
messages: [{ role: 'user', content: '求解:2x + 5 = 13' }]
}, {
reasoning: 'medium' // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'(xhigh 在非 OpenAI 提供商上映射到 high)
});
// 访问思考和文本块
for (const block of response.content) {
if (block.type === 'thinking') {
console.log('思考:', block.thinking);
} else if (block.type === 'text') {
console.log('响应:', block.text);
}
}
提供商特定选项
要进行精细控制,使用提供商特定选项:
import { getModel, complete } from '@mariozechner/pi-ai';
// OpenAI 推理(o1、o3、gpt-5)
const openaiModel = getModel('openai', 'gpt-5-mini');
await complete(openaiModel, context, {
reasoningEffort: 'medium',
reasoningSummary: 'detailed' // 仅 OpenAI Responses API
});
// Anthropic 思考(Claude Sonnet 4)
const anthropicModel = getModel('anthropic', 'claude-sonnet-4-20250514');
await complete(anthropicModel, context, {
thinkingEnabled: true,
thinkingBudgetTokens: 8192 // 可选的 token 限制
});
// Google Gemini 思考
const googleModel = getModel('google', 'gemini-2.5-flash');
await complete(googleModel, context, {
thinking: {
enabled: true,
budgetTokens: 8192 // -1 表示动态,0 表示禁用
}
});
流式传输思考内容
流式传输时,思考内容通过特定事件传递:
const s = streamSimple(model, context, { reasoning: 'high' });
for await (const event of s) {
switch (event.type) {
case 'thinking_start':
console.log('[模型开始思考]');
break;
case 'thinking_delta':
process.stdout.write(event.delta); // 流式传输思考内容
break;
case 'thinking_end':
console.log('\n[思考完成]');
break;
}
}
停止原因
每个 AssistantMessage 都包含一个 stopReason 字段,指示生成如何结束:
"stop"- 正常完成,模型完成了其响应"length"- 输出达到最大 token 限制"toolUse"- 模型正在调用工具并期望工具结果"error"- 生成过程中发生错误"aborted"- 请求通过中止信号被取消
AssistantMessage 还可能包含 responseId,当底层 API 暴露时,这是提供商特定的上游响应或消息标识符。不要假设它在所有提供商中都存在。
错误处理
当请求以错误结束时(包括中止和工具调用验证错误),流式 API 会发出错误事件:
// 在流式传输中
for await (const event of stream) {
if (event.type === 'error') {
// event.reason 是 "error" 或 "aborted"
// event.error 是包含部分内容的 AssistantMessage
console.error(`错误(${event.reason}):`, event.error.errorMessage);
console.log('部分内容:', event.error.content);
}
}
// 最终消息将包含错误详情
const message = await stream.result();
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
console.error('请求失败:', message.errorMessage);
// message.content 包含错误前收到的任何部分内容
// message.usage 包含部分 token 计数和成本
}
中止请求
import { getModel, stream } from '@mariozechner/pi-ai';
const model = getModel('openai', 'gpt-4o-mini');
const controller = new AbortController();
// 2 秒后中止
setTimeout(() => controller.abort(), 2000);
const s = stream(model, {
messages: [{ role: 'user', content: '写一个长故事' }]
}, {
signal: controller.signal
});
for await (const event of s) {
if (event.type === 'text_delta') {
process.stdout.write(event.delta);
} else if (event.type === 'error') {
// event.reason 告诉你是 "error" 还是 "aborted"
console.log(`${event.reason === 'aborted' ? '已中止' : '错误'}:`, event.error.errorMessage);
}
}
// 获取结果(如果中止可能是部分的)
const response = await s.result();
if (response.stopReason === 'aborted') {
console.log('请求被中止:', response.errorMessage);
console.log('收到的部分内容:', response.content);
console.log('使用的 Token:', response.usage);
}
中止后继续
被中止的消息可以添加到对话上下文中,并在后续请求中继续:
const context = {
messages: [
{ role: 'user', content: '详细解释量子计算' }
]
};
// 第一个请求在 2 秒后被中止
const controller1 = new AbortController();
setTimeout(() => controller1.abort(), 2000);
const partial = await complete(model, context, { signal: controller1.signal });
// 将部分响应添加到上下文
context.messages.push(partial);
context.messages.push({ role: 'user', content: '请继续' });
// 继续对话
const continuation = await complete(model, context);
调试供应商载荷
使用 onPayload 回调检查发送给供应商的请求载荷。这对于调试请求格式问题或供应商验证错误很有用。
const response = await complete(model, context, {
onPayload: (payload) => {
console.log('供应商载荷:', JSON.stringify(payload, null, 2));
}
});
stream、complete、streamSimple 和 completeSimple 都支持此回调。
API、模型和供应商
本库使用 API 实现的注册表。内置 API 包括:
anthropic-messages:Anthropic Messages API(streamAnthropic、AnthropicOptions)google-generative-ai:Google Generative AI API(streamGoogle、GoogleOptions)google-gemini-cli:Google Cloud Code Assist API(streamGoogleGeminiCli、GoogleGeminiCliOptions)google-vertex:Google Vertex AI API(streamGoogleVertex、GoogleVertexOptions)mistral-conversations:Mistral Conversations API(streamMistral、MistralOptions)openai-completions:OpenAI Chat Completions API(streamOpenAICompletions、OpenAICompletionsOptions)openai-responses:OpenAI Responses API(streamOpenAIResponses、OpenAIResponsesOptions)openai-codex-responses:OpenAI Codex Responses API(streamOpenAICodexResponses、OpenAICodexResponsesOptions)azure-openai-responses:Azure OpenAI Responses API(streamAzureOpenAIResponses、AzureOpenAIResponsesOptions)bedrock-converse-stream:Amazon Bedrock Converse API(streamBedrock、BedrockOptions)
供应商和模型
供应商通过特定 API 提供模型。例如:
- Anthropic 模型使用
anthropic-messagesAPI - Google 模型使用
google-generative-aiAPI - OpenAI 模型使用
openai-responsesAPI - Mistral 模型使用
mistral-conversationsAPI - xAI、Cerebras、Groq 等模型使用
openai-completionsAPI(OpenAI 兼容)
查询供应商和模型
import { getProviders, getModels, getModel } from '@mariozechner/pi-ai';
// 获取所有可用的提供商
const providers = getProviders();
console.log(providers); // ['openai', 'anthropic', 'google', 'xai', 'groq', ...]
// 从提供商获取所有模型(完全类型化)
const anthropicModels = getModels('anthropic');
for (const model of anthropicModels) {
console.log(`${model.id}: ${model.name}`);
console.log(` API: ${model.api}`); // 'anthropic-messages'
console.log(` 上下文:${model.contextWindow} tokens`);
console.log(` 视觉:${model.input.includes('image')}`);
console.log(` 推理:${model.reasoning}`);
}
// 获取特定模型(提供商和模型 ID 在 IDE 中都有自动补全)
const model = getModel('openai', 'gpt-4o-mini');
console.log(`通过 ${model.api} API 使用 ${model.name}`);
自定义模型
你可以为本地推理服务器或自定义端点创建自定义模型:
import { Model, stream } from '@mariozechner/pi-ai';
// 示例:带有自定义标头的自定义端点(绕过 Cloudflare 机器人检测)
const proxyModel: Model<'anthropic-messages'> = {
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4 (代理)',
api: 'anthropic-messages',
provider: 'custom-proxy',
baseUrl: 'https://proxy.example.com/v1',
reasoning: true,
input: ['text', 'image'],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'X-Custom-Auth': 'bearer-token-here'
}
};
// 使用自定义模型
const response = await stream(proxyModel, context, {
apiKey: 'xxxx'
});
一些 OpenAI 兼容服务器不理解用于支持推理的模型的 developer 角色。对于这些提供商,将 compat.supportsDeveloperRole 设置为 false,这样系统提示会作为 system 消息发送。如果服务器也不支持 reasoning_effort,也将 compat.supportsReasoningEffort 设置为 false。
这通常适用于 Ollama、vLLM、SGLang 和类似的 OpenAI 兼容服务器。你可以在提供商级别或每个模型设置 compat。
const ollamaReasoningModel: Model<'openai-completions'> = {
id: 'gpt-oss:20b',
name: 'GPT-OSS 20B (Ollama)',
api: 'openai-completions',
provider: 'ollama',
baseUrl: 'http://localhost:11434/v1',
reasoning: true,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 32000,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
}
};
OpenAI 兼容性设置
openai-completions API 由许多提供商实现,但有细微差别。默认情况下,本库根据 baseUrl 为少数已知的 OpenAI 兼容提供商(Cerebras、xAI、Chutes、DeepSeek、zAi、OpenCode 等)自动检测兼容性设置。对于自定义代理或未知端点,你可以通过 compat 字段覆盖这些设置。对于 openai-responses 模型,compat 字段仅支持 Responses 特定的标志。
interface OpenAICompletionsCompat {
supportsStore?: boolean; // 提供商是否支持 `store` 字段(默认:true)
supportsDeveloperRole?: boolean; // 提供商是否支持 `developer` 角色与 `system`(默认:true)
supportsReasoningEffort?: boolean; // 提供商是否支持 `reasoning_effort`(默认:true)
supportsUsageInStreaming?: boolean; // 提供商是否支持 `stream_options: { include_usage: true }`(默认:true)
supportsStrictMode?: boolean; // 提供商是否支持工具定义中的 `strict`(默认:true)
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // 使用哪个字段名(默认:max_completion_tokens)
requiresToolResultName?: boolean; // 工具结果是否需要 `name` 字段(默认:false)
requiresAssistantAfterToolResult?: boolean; // 工具结果后是否必须有助手消息(默认:false)
requiresThinkingAsText?: boolean; // 思考块是否必须转换为文本(默认:false)
thinkingFormat?: 'openai' | 'zai' | 'qwen'; // 推理参数格式:'openai' 使用 reasoning_effort,'zai' 使用 thinking: { type: "enabled" },'qwen' 使用 enable_thinking: boolean(默认:openai)
openRouterRouting?: OpenRouterRouting; // OpenRouter 路由偏好(默认:{})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway 路由偏好(默认:{})
}
interface OpenAIResponsesCompat {
// 保留供将来使用
}
如果未设置 compat,本库会回退到基于 URL 的检测。如果部分设置了 compat,未指定的字段使用检测到的默认值。这对于以下情况很有用:
- LiteLLM 代理:可能不支持
store字段 - 自定义推理服务器:可能使用非标准字段名
- 自托管端点:可能有不同的功能支持
类型安全
模型按其 API 类型化,这保持了模型元数据的准确性。当你直接调用提供商函数时,会强制执行提供商特定的选项类型。通用的 stream 和 complete 函数接受带有附加提供商字段的 StreamOptions。
import { streamAnthropic, type AnthropicOptions } from '@mariozechner/pi-ai';
// TypeScript 知道这是一个 Anthropic 模型
const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
const options: AnthropicOptions = {
thinkingEnabled: true,
thinkingBudgetTokens: 2048
};
await streamAnthropic(claude, context, options);
跨供应商切换
本库支持在同一对话中在不同 LLM 提供商之间无缝切换。这允许你在保持上下文的同时在对话中途切换模型,包括思考块、工具调用和工具结果。
工作原理
当来自一个供应商的消息被发送到不同的供应商时,本库会自动转换它们以保持兼容性:
- 用户和工具结果消息原样传递
- 来自相同供应商/API 的消息按原样保留
- 来自不同供应商的消息将其思考块转换为带有
<thinking>标签的文本 - 工具调用和普通文本保持不变
示例:多提供商对话
import { getModel, complete, Context } from '@mariozechner/pi-ai';
// 从 Claude 开始
const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
const context: Context = {
messages: []
};
context.messages.push({ role: 'user', content: '25 * 18 是多少?' });
const claudeResponse = await complete(claude, context, {
thinkingEnabled: true
});
context.messages.push(claudeResponse);
// 切换到 GPT-5 - 它会将 Claude 的思考视为带 <thinking> 标签的文本
const gpt5 = getModel('openai', 'gpt-5-mini');
context.messages.push({ role: 'user', content: '那个计算正确吗?' });
const gptResponse = await complete(gpt5, context);
context.messages.push(gptResponse);
// 切换到 Gemini
const gemini = getModel('google', 'gemini-2.5-flash');
context.messages.push({ role: 'user', content: '最初的问题是什么?' });
const geminiResponse = await complete(gemini, context);
供应商兼容性
所有提供商都可以处理来自其他提供商的消息,包括:
- 文本内容
- 工具调用和工具结果(包括工具结果中的图像)
- 思考/推理块(转换为带标签的文本以实现跨提供商兼容性)
- 带有部分内容的中止消息
这使你能够实现灵活的工作流程:
- 从快速模型开始进行初始响应
- 切换到更强大的模型进行复杂推理
- 使用专门的模型处理特定任务
- 在提供商故障时保持对话连续性
上下文序列化
Context 对象可以使用标准 JSON 方法轻松序列化和反序列化,使得持久化对话、实现聊天历史或在服务之间传递上下文变得简单:
import { Context, getModel, complete } from '@mariozechner/pi-ai';
// 创建并使用上下文
const context: Context = {
systemPrompt: '你是一个有帮助的助手。',
messages: [
{ role: 'user', content: '什么是 TypeScript?' }
]
};
const model = getModel('openai', 'gpt-4o-mini');
const response = await complete(model, context);
context.messages.push(response);
// 序列化整个上下文
const serialized = JSON.stringify(context);
console.log('序列化上下文大小:', serialized.length, '字节');
// 保存到数据库、localStorage、文件等
localStorage.setItem('conversation', serialized);
// 稍后:反序列化并继续对话
const restored: Context = JSON.parse(localStorage.getItem('conversation')!);
restored.messages.push({ role: 'user', content: '告诉我更多关于它的类型系统' });
// 用任何模型继续
const newModel = getModel('anthropic', 'claude-3-5-haiku-20241022');
const continuation = await complete(newModel, restored);
Q&A
Q1: 为什么ai层需要定义自己的Message?
核心原因:抽象供应商差异。
各供应商消息格式差异很大:
- Anthropic: content: ContentBlock[] (text/image/tool_use 混合)
- OpenAI: content: string | MessageContent[],工具调用在 tool_calls 字段
- Google: 完全不同的 parts 结构
统一格式让上层(coding-agent)无需关心供应商细节。
Q2:使用 TypeBox 定义工具参数有什么好处?
Schema 即类型,一份定义两处使用。
// types.ts:217-221
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters; // TypeBox schema
}
好处:
- 编译时 - Static 从 schema 推断 TypeScript 类型
- 运行时 - validation.ts 用 AJV 验证 LLM 返回的参数,自动类型转换(如 “123” → 123)
避免传统方案中同时维护 JSON Schema 和 TypeScript 类型的重复。
Q3:跨供应商切换是如何做到的?
三层机制:
1. Model 对象即配置,Registry 按 api 字段路由
Model<TApi> 是一个纯数据对象,包含了路由所需的全部信息:
interface Model<TApi extends Api> {
id: string; // 模型 ID,如 "claude-opus-4-5"
api: TApi; // API 协议标识,如 "anthropic-messages"
provider: string; // 提供商,如 "anthropic"
baseUrl: string; // 请求地址
// ...定价、上下文窗口、能力等
}
api-registry.ts 维护一张 Map<api, StreamFunction> 表,key 是协议类型字符串而不是具体模型。stream.ts 查表分发:
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options);
同一协议下的所有模型(无论哪个供应商)共用一个 StreamFunction,该函数内部读取 model.baseUrl、model.id 等字段构造实际请求。切换模型只需换一个 Model 对象,routing 自动跟着走。
2. 懒加载注册
register-builtins.ts 在模块加载时自动注册所有内置 provider,但每个 provider 用 createLazyStream() 包装,首次调用时才动态 import() 实际实现模块,避免启动时加载所有 SDK(Bedrock SDK 很重)。
3. 消息转换 - transform-messages.ts 处理兼容性
每次请求前,各 provider 的 convertMessages 会先调用 transformMessages,对历史消息做跨 provider 兼容处理:
- 同模型:保留 thinking signature、text signature 等 provider 私有字段
- 跨模型:thinking block 降级为纯文本,删除 vendor-specific 字段(如 Google 的
thoughtSignature) - tool call ID 规范化(见 Q4)
4. 统一事件流
所有供应商输出标准化 AssistantMessageEvent,包含 text_delta、thinking_delta、toolcall_delta 等细粒度事件,上层消费方无需感知底层 provider。
切换发生在哪里(以 pi CLI 为例)
用户执行 /model 并选择新模型
↓
interactive-mode.ts: showModelSelector()
↓
ModelSelectorComponent.handleSelect(model)
├─ settingsManager.setDefaultModelAndProvider() // 持久化默认模型
└─ onSelectCallback(model)
↓
AgentSession.setModel(model):
1. 验证 API key(无 key 直接 throw)
2. agent.setModel(model) // Agent 层实际切换
3. sessionManager.appendModelChange() // 写 session 日志
4. settingsManager.setDefaultModelAndProvider() // 再次持久化
5. setThinkingLevel() // 重新 clamp thinking 级别
6. _emitModelSelect() // 通知扩展
Agent.setModel() 本身只是 this._state.model = m,下一次 prompt() 调用时自然使用新模型。历史消息(Context.messages)原封不动传递,transformMessages 负责在发请求前做格式兼容。
Q4:transformMessages 里 tool call ID 和 thinking block 的处理细节
为什么要 normalize tool call ID?
tool call ID 由 assistant 消息里的供应商生成,切换 provider 后历史消息里的 ID 不一定符合新 provider 的格式要求。最典型的冲突:
- OpenAI Responses API 生成的 ID 格式是
{call_id}|{item_id},item_id部分 450+ 字符,含+、/、=等 base64 字符 - Anthropic 要求 ID 只能含
[a-zA-Z0-9_-],最长 64 字符 - OpenAI Completions 最长 40 字符
每个 provider 在调用 transformMessages 时传入自己的 normalizeToolCallId 回调:
| Provider | 规则 |
|---|---|
| Anthropic | replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) |
| OpenAI Completions | 取 | 前的 call_id,非法字符换 _,截到 40 |
| OpenAI Responses | 同 provider 内保持原结构,跨 provider 来的 item_id 用 fc_${shortHash(itemId)} 重建 |
transformMessages 内部用 toolCallIdMap 同步改写配对的 toolResult,保证 call/result ID 一致。
redacted 和 thinkingSignature 是什么?
ThinkingContent 有两个可选字段:
interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string; // provider 私有,含义各异
redacted?: boolean; // Anthropic 专有:加密内容
}
redacted(Anthropic 专有):当 Claude 的 extended thinking 被安全过滤时,Anthropic 不返回明文推理,而是返回一个 redacted_thinking block,里面是服务端加密的不透明 payload,存在 thinkingSignature 里,thinking 字段只有占位符 "[Reasoning redacted]"。Anthropic 需要这个 payload 维持多轮对话的 thinking 连续性,原封不动传回即可。其他 provider 拿到这串数据毫无意义,所以跨模型时直接丢弃。
thinkingSignature(各 provider 用途不同):
| Provider | 存的内容 | 作用 |
|---|---|---|
| Anthropic | Anthropic 返回的 signature 字符串 |
下轮请求时验证 thinking 完整性(防篡改) |
| OpenAI Responses | 整个 reasoning item 的 JSON 序列化 |
下轮请求时把完整的 reasoning item 放回 |
thoughtSignature opaque string |
复用 thought context,省 token |
跨模型时的完整处理逻辑:
redacted = true?
同模型 → 原样保留(Anthropic 多轮连续性需要)
跨模型 → 丢弃(其他 provider 无法解密)
有 thinkingSignature 且同模型?
→ 整块保留,即使 thinking 文本为空
(OpenAI 加密 reasoning 场景:文本空,但 JSON item 存在 signature 里,下轮需要)
thinking 为空?
→ 丢弃
同模型 → 原样保留
跨模型且有内容 → 降级为纯 text block,signature 丢弃
{ type: "text", text: block.thinking }
跨模型时 thinkingSignature 一律丢弃,因为它是 provider 私有的 opaque 数据,发给其他 provider 会报错或被忽略。