跳到主内容
创建时间:最后更新:作者:白川

让接入的模型调用工具

看清工具调用在模型层的表达方式,区分模型请求与实际执行,校验参数并把工具结果正确关联回上下文。

让接入的模型调用工具

模型能回答文本问题,不代表你的接入已经支持工具调用。这篇用一个可以人工核对的求和工具,检查模型声明、调用参数、程序执行和结果回填是否完整连通。

沿用模型配置中的项目与 connection.ts。这里直接使用模型层手动处理调用,便于观察协议;完整智能体运行时会替你组织大部分循环。

工具调用在模型层是什么

模型层只有两个概念:工具声明(Tool)和工具调用块(ToolCall)。它们都不是函数,而是数据。

工具声明的类型:

typescript
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 对象。

工具调用块是助手消息内容里的第三种块:

typescript
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 用于动态加载或带命名空间的工具。

助手消息的内容块因此有三种类型:

typescript
export interface AssistantMessage {
	role: "assistant";
	content: (TextContent | ThinkingContent | ToolCall)[];
	// ... 其它字段 ...
	stopReason: StopReason;
}

TextContent 是文本,ThinkingContent 是思考过程,ToolCall 是工具请求。

停止原因标记工具请求

模型请求调用工具时,消息的 stopReason 是 "toolUse"。这是七值 StopReason 里的一个:

typescript
export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";

"toolUse" 的含义是"这一轮我需要工具结果才能继续",不是"调用成功了"。工具是否执行、执行成不成功,是另一层的事。

另外注意,模型请求工具和模型调用工具是两件事。请求只是数据,真正的执行是你的程序做的。

先选一个结果明确的工具

练习要求计算 17、29、53 的和。工具只接收最多二十个数字,返回加法结果,不读取文件、不发送请求,也没有写入副作用。

求和结果可以人工核对为 99。因此验收时除了看模型说什么,还能观察工具是否真正执行、程序算出的值是否正确。

创建工具调用程序

新建 src/check-tool.ts:

typescript
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("达到轮次上限,未获得最终回答")
}

运行:

bash
node --env-file=.env --import tsx src/check-tool.ts

模型先收到工具的名称、说明和参数结构。它返回的工具请求不是已执行的函数;真正执行的是程序里的加法。执行之前先检查工具名称与完整参数,模型不能凭空调用未注册能力。

参数校验会发生什么

validateToolArguments() 是模型层提供的校验入口。它做三件事:

typescript
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 校验,并且先做宽松强制转换。校验器的编译选项是:

typescript
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),最终消息长这样:

text
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 只编译一次。

模型层与运行时层的分工

工具调用涉及两个包。模型层只负责"把请求表达出来",运行时负责"把请求执行掉"。

关注点模型层(pi-ai)运行时(pi-agent-core)
工具声明
Tool 类型:name、description、parameters
AgentTool 继承 Tool,增加 label、execute 等
请求表达
ToolCall 内容块
读取并分发
参数校验
validateToolArguments()
自动调用它;失败时回填错误结果
执行
不负责
execute()
结果回填
ToolResultMessage 类型
构造并追加到上下文
权限检查
不负责
beforeToolCall / afterToolCall 钩子

模型层不知道你的函数怎么实现,运行时不知道模型怎么生成参数。 分工清楚的好处是:换模型只影响前者,接工具只影响后者。

模型层自己提供的 Tool 类型是纯声明:

typescript
export interface Tool<TParameters extends object = object> {
	name: string;
	description: string;
	parameters: TParameters;
	constrainedSampling?: false | ConstrainedSamplingConfig;
}

它没有 execute。所以在模型层手动写循环(本篇的做法)时,你要自己判断"这是哪个工具"然后调用对应函数;用运行时(Agent Core)时,这一步由它完成。

回填结果不能漏掉调用 ID

助手消息先加入上下文,随后为其中每个工具请求追加对应的 toolResult。toolResult 消息的类型:

typescript
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 更新:

typescript
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 with id, name, arguments

完整 ≠ 已校验。参数完整时仍未通过 schema 校验。校验是执行前的独立步骤。

流式过程中的参数是不完整 JSON,官方文档提醒:

During toolcall_delta events, arguments contains the best-effort parse of partial JSON. Fields may be missing or incomplete - always check for existence before use. At minimum, arguments will be an empty object {}, never undefined.

想做"参数边生成边预览"的 UI,可以读 partial.content[contentIndex].arguments,但必须假设每个字段都可能缺失。

Google provider 不支持函数调用流式,会收到一个包含完整参数的 toolcall_delta。

让 provider 约束生成

Tool 上的 constrainedSampling 字段可以要求服务端约束模型的生成:

typescript
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 或正则语法变体:

typescript
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 或工具格式,先对照真实错误检查兼容能力。

本例使用模拟模型验证了参数校验和回填顺序,未代表所有远程服务均已联调。一般工具执行、权限和副作用继续在工具专题中展开。

参考资料