让接入的模型调用工具
看清工具调用在模型层的表达方式,区分模型请求与实际执行,校验参数并把工具结果正确关联回上下文。
让接入的模型调用工具
模型能回答文本问题,不代表你的接入已经支持工具调用。这篇用一个可以人工核对的求和工具,检查模型声明、调用参数、程序执行和结果回填是否完整连通。
沿用模型配置中的项目与 connection.ts。这里直接使用模型层手动处理调用,便于观察协议;完整智能体运行时会替你组织大部分循环。
工具调用在模型层是什么
模型层只有两个概念:工具声明(Tool)和工具调用块(ToolCall)。它们都不是函数,而是数据。
工具声明的类型:
export interface Tool<TParameters extends object = object> {
name: string;
description: string;
parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
}parameters 是参数的 JSON Schema,可以是 @earendil-works/pi-schema 的构建器产物、TypeBox schema,或者手写的普通 JSON Schema 对象。
工具调用块是助手消息内容里的第三种块:
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: JsonObject;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
/** OpenAI Responses namespace for calls to dynamically loaded or namespaced tools. */
namespace?: string;
/**
* Set when the model's complete arguments text was not a valid JSON object. `arguments` is then `{}`.
* Describes the problem and includes the text the model sent; tool argument validation fails with it.
*/
argumentsError?: string;
}六个字段里有两个容易漏掉:
argumentsError——当模型返回的完整参数文本不是合法 JSON 对象时设置,此时arguments是{}。这个字段会直接导致工具参数校验失败,错误信息里带上模型原始发来的文本。namespace——OpenAI Responses 用于动态加载或带命名空间的工具。
助手消息的内容块因此有三种类型:
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
// ... 其它字段 ...
stopReason: StopReason;
}TextContent 是文本,ThinkingContent 是思考过程,ToolCall 是工具请求。
停止原因标记工具请求
模型请求调用工具时,消息的 stopReason 是 "toolUse"。这是七值 StopReason 里的一个:
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";"toolUse" 的含义是"这一轮我需要工具结果才能继续",不是"调用成功了"。工具是否执行、执行成不成功,是另一层的事。
另外注意,模型请求工具和模型调用工具是两件事。请求只是数据,真正的执行是你的程序做的。
先选一个结果明确的工具
练习要求计算 17、29、53 的和。工具只接收最多二十个数字,返回加法结果,不读取文件、不发送请求,也没有写入副作用。
求和结果可以人工核对为 99。因此验收时除了看模型说什么,还能观察工具是否真正执行、程序算出的值是否正确。
创建工具调用程序
新建 src/check-tool.ts:
import { type Context, type Tool, validateToolArguments } from "@earendil-works/pi-ai"
import { createConnection, responseText } from "./connection.js"
const parameters = {
type: "object",
properties: { items: { type: "array", items: { type: "number" }, maxItems: 20 } },
required: ["items"],
additionalProperties: false,
} as const
const tool: Tool = {
name: "sum_numbers",
description: "计算数组内所有数字之和。需要准确求和时调用。",
parameters,
}
const { models, model } = createConnection()
const context: Context = {
systemPrompt: "计算时调用 sum_numbers,收到结果后用中文回答。",
tools: [tool],
messages: [{ role: "user", content: "请用工具计算 17、29、53 的和。", timestamp: Date.now() }],
}
let called = false
let completed = false
const signal = AbortSignal.timeout(30_000)
for (let round = 0; round < 4; round += 1) {
const reply = await models.completeSimple(model, context, { signal, maxTokens: 512 })
responseText(reply)
context.messages.push(reply)
const calls = reply.content.filter((block) => block.type === "toolCall")
if (calls.length === 0) {
if (!called) {
throw new Error("本次未发生工具调用,不能认定工具能力验收通过")
}
console.log(responseText(reply))
completed = true
break
}
for (const call of calls) {
let text: string
let isError = false
try {
if (call.name !== tool.name) {
throw new Error(`未知工具:${call.name}`)
}
const args = validateToolArguments(tool, call)
const items: unknown = args.items
if (
!Array.isArray(items) ||
!items.every((item) => typeof item === "number" && Number.isFinite(item))
) {
throw new Error("工具参数必须是有限数字数组")
}
const total = items.reduce((sum, item) => sum + item, 0)
text = String(total)
called = true
console.error("实际工具结果:", text)
} catch (error) {
isError = true
text = error instanceof Error ? error.message : String(error)
}
context.messages.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content: [{ type: "text", text }],
isError,
timestamp: Date.now(),
})
}
}
if (!completed) {
throw new Error("达到轮次上限,未获得最终回答")
}运行:
node --env-file=.env --import tsx src/check-tool.ts模型先收到工具的名称、说明和参数结构。它返回的工具请求不是已执行的函数;真正执行的是程序里的加法。执行之前先检查工具名称与完整参数,模型不能凭空调用未注册能力。
参数校验会发生什么
validateToolArguments() 是模型层提供的校验入口。它做三件事:
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
if (toolCall.argumentsError) {
throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`);
}
// A copy, so tools that modify their arguments never modify the tool call in the transcript.
const result = getValidator(tool.parameters).parse(structuredClone(toolCall.arguments));
if (result.ok) {
return result.value;
}
const errors = result.errors.map((error) => ` - ${formatValidationPath(error.path)}: ${error.message}`).join("\n");
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;
throw new Error(errorMessage);
}第一步先看 argumentsError——模型发的不是合法 JSON 时,这里直接抛出。
第二步用 pi-schema 校验,并且先做宽松强制转换。校验器的编译选项是:
const validator = compile(schema, { formats, coerce: true, unresolvedRefs: "reject" });coerce: true 意味着校验前会做一轮容错转换:数字字符串转数字、"true" 转 true、单值转一元素数组、可选属性里的 null 被丢弃。所以执行器拿到的参数不一定等于模型原始发来的 JSON——这一点在写业务校验时要记住。
unresolvedRefs: "reject" 处理的是 MCP 服务这类外部来源的 schema 里可能出现的悬空 $ref:它只让该位置的参数失败(错误是 unresolved reference ...),而不是让整个工具的每次调用都失败。
第三步拼装错误消息。错误路径从 JSON Pointer 转成点号路径(/edits/0/oldText → edits.0.oldText),最终消息长这样:
Validation failed for tool "read_file":
- limit: expected number, got string
Received arguments:
{
"path": "a.txt",
"limit": "ten"
}缺少必填字段时是 - limit: missing。注意错误里的 Received arguments 是模型原始发的内容,不是转换后的值——排查时这个差异很重要。
校验器是按 schema 对象缓存的(WeakMap),同一个工具的 schema 只编译一次。
模型层与运行时层的分工
工具调用涉及两个包。模型层只负责"把请求表达出来",运行时负责"把请求执行掉"。
模型层不知道你的函数怎么实现,运行时不知道模型怎么生成参数。 分工清楚的好处是:换模型只影响前者,接工具只影响后者。
模型层自己提供的 Tool 类型是纯声明:
export interface Tool<TParameters extends object = object> {
name: string;
description: string;
parameters: TParameters;
constrainedSampling?: false | ConstrainedSamplingConfig;
}它没有 execute。所以在模型层手动写循环(本篇的做法)时,你要自己判断"这是哪个工具"然后调用对应函数;用运行时(Agent Core)时,这一步由它完成。
回填结果不能漏掉调用 ID
助手消息先加入上下文,随后为其中每个工具请求追加对应的 toolResult。toolResult 消息的类型:
export type ToolResultMessage<TDetails = JsonValue> = {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: JsonRepresentation<TDetails>;
usage?: Usage;
/** Calls this tool made to other tools. Kept for the session record; not sent to the model. */
nestedCalls?: NestedToolCalls;
isError: boolean;
timestamp: number;
durationMs?: number;
};toolCallId 来自请求本身,toolName 标识工具,isError 明确执行是否失败。
后续请求看到这组消息,才能把"模型要求计算"和"程序算得 99"对应起来。如果只把字符串 99 当成新用户消息发回去,虽然某些模型也可能回答,但会丢失工具协议中的调用关联。
参数校验失败时回填错误结果,不把错误包装成成功内容。上面的循环把校验异常捕获后转成 isError: true 的工具结果——模型下一轮就能看到这个错误并自行纠正。
流式过程中的工具调用
流式时工具参数是逐步到达的。toolcall_start 事件的参数是 provider 特定的原始形态,toolcall_delta 携带后续的 JSON 更新:
type ToolCallEvent =
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage };toolcall_end 才是参数完整的时候。官方 README 明确说:
toolcall_end| Tool call complete |toolCall: Complete, but not schema-validated, tool call withid,name,arguments
完整 ≠ 已校验。参数完整时仍未通过 schema 校验。校验是执行前的独立步骤。
流式过程中的参数是不完整 JSON,官方文档提醒:
During
toolcall_deltaevents,argumentscontains the best-effort parse of partial JSON. Fields may be missing or incomplete - always check for existence before use. At minimum,argumentswill be an empty object{}, neverundefined.
想做"参数边生成边预览"的 UI,可以读 partial.content[contentIndex].arguments,但必须假设每个字段都可能缺失。
Google provider 不支持函数调用流式,会收到一个包含完整参数的 toolcall_delta。
让 provider 约束生成
Tool 上的 constrainedSampling 字段可以要求服务端约束模型的生成:
export type ConstrainedSamplingConfig =
| { type: "json_schema"; strict: "prefer" | "require" }
| { type: "grammar"; variants: GrammarVariants };strict: "prefer" 表示支持时优先使用严格 schema 约束,不支持时退回普通工具调用。strict: "require" 表示不支持就让请求失败。constrainedSampling: false 显式退出,行为等同于不写这个字段。
严格 JSON Schema 约束采样在 OpenAI、Anthropic、支持的 Amazon Bedrock Converse 模型、Mistral 以及通过 Google Generative AI 和 Vertex 适配器的 Gemini 3 工具调用上受支持。Gemini 用 VALIDATED 函数调用模式;更早的 Gemini 版本在 prefer 下退回,在 require 下拒绝。
语法(grammar)约束是另一条路径,OpenAI Responses 和 Chat Completions 支持 OpenAI Lark 或正则语法变体:
const patchTool: Tool = {
name: "apply_patch",
description: "Apply a patch",
parameters: obj({ input: str() }),
constrainedSampling: {
type: "grammar",
variants: {
openai_lark: 'start: /.+/s'
}
}
};语法工具要求参数 schema 是恰好只有一个必填字符串属性的对象。
约束采样是服务端行为,与本地校验是两层。它能减少无效参数,但不能替代本地校验。
什么时候算通过
终端需要出现"实际工具结果:99",模型随后基于结果回答。只返回正确数字却没有工具执行,本例会报告未验收通过,因为模型可能自行算出了答案。
工具不一定每次都被调用。系统指令和任务明确要求使用,但最终仍需观察返回的内容块;生产任务需要根据实际模型和协议设计约束,不能仅靠提示词假定调用一定发生。
循环最多四轮,使用同一个 30 秒信号约束整个练习。时间和轮次都是本例教学预算,不是模型能力保证。
切换模型时重新跑一遍
改变 .env 的 Provider 与模型 ID,重新运行相同程序。文本成功、工具成功应作为两项分开的验收结果。某个服务若不接受当前 Schema 或工具格式,先对照真实错误检查兼容能力。
本例使用模拟模型验证了参数校验和回填顺序,未代表所有远程服务均已联调。一般工具执行、权限和副作用继续在工具专题中展开。