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

通过工具调用提交结构化结果

用专门的结果提交工具接收函数分析,看清参数校验发生在哪一步、如何回填错误让模型重试,并控制重复接收。

通过工具调用提交结构化结果

从说明文字中解析 JSON 之外,还可以给智能体一个专门的结果提交工具。模型把分析结果放进工具参数,运行时验证参数,程序在执行器里接收数据。

本篇沿用Schema 校验的 schema.ts,以及第一篇的 connection.ts 和 lesson.ts。目的是明确"已提交的结果"在哪里产生,不是让模型执行文件写入或其他外部操作。

模型提交工具调用时,参数校验在哪一步

这是本篇最需要说清楚的问题。答案分两个阶段:

第一阶段——服务端生成时的校验。 模型生成工具参数时,服务端会尽量保证它符合工具声明的 schema。Pi 通过 constrainedSampling 请求这个行为(见下)。但这是服务端行为,不是保证。

第二阶段——执行前的本地校验。 这一步在 Agent Core 里做,位置是 packages/agent/src/agent-loop.ts 的 prepareToolCall():

typescript
async function prepareToolCall(
	currentContext: AgentContext,
	assistantMessage: AssistantMessage,
	toolCall: AgentToolCall,
	config: ToolCallHooks,
	signal: AbortSignal | undefined,
	tools: readonly AgentTool<any>[] = currentContext.tools ?? [],
): Promise<PreparedToolCall | ImmediateToolCallOutcome> {
	const tool = tools.find((t) => t.name === toolCall.name);
	if (!tool) {
		return {
			kind: "immediate",
			result: createErrorToolResult(`Tool ${toolCall.name} not found`),
			isError: true,
		};
	}

	try {
		const preparedToolCall = prepareToolCallArguments(tool, toolCall);
		const validatedArgs = validateToolArguments(tool, preparedToolCall);
		if (config.beforeToolCall) {
			// ... 权限钩子 ...
		}
		return { kind: "prepared", toolCall, tool, args: validatedArgs };
	} catch (error) {
		return {
			kind: "immediate",
			result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
			isError: true,
		};
	}
}

这条路径的顺序是固定的:

text
找到工具 → prepareArguments 垫片 → validateToolArguments 校验 → beforeToolCall 钩子 → 执行

三个观察点:

  1. 找不到工具就直接返回错误结果,不进入执行。错误消息是 Tool xxx not found。
  2. prepareArguments 在校验之前。它是"原始参数"到"待校验参数"的转换层。
  3. 校验失败被捕获,转成错误工具结果。execute() 根本不会被调用。

关键结论:execute() 拿到的参数一定是校验(且转换)过的。beforeToolCall 钩子里的 args 同样如此。

prepareArguments 垫片

prepareArguments 是 AgentTool 上的可选钩子:

typescript
interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
	/**
	 * Optional compatibility shim for raw tool-call arguments before schema validation.
	 * Must return an object that matches `TParameters`.
	 */
	prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
	// ...
}

它的用途是在 schema 校验之前做参数形状的兼容处理。典型场景是模型还在用旧参数名,或者参数结构需要先规整:

typescript
const tool: AgentTool<typeof parameters> = {
  name: "read_file",
  label: "Read",
  description: "读取文件",
  parameters: obj({ path: str() }),
  // 模型可能仍发旧参数名 filePath
  prepareArguments: (args) => {
    const raw = (args ?? {}) as Record<string, unknown>;
    return { path: (raw.path ?? raw.filePath) as string };
  },
  async execute(_id, params) {
    return { content: [{ type: "text", text: String(params.path) }], details: {} };
  },
};

实现代码:

typescript
function prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {
	if (!tool.prepareArguments) {
		return toolCall;
	}
	const preparedArguments = tool.prepareArguments(toolCall.arguments);
	if (preparedArguments === toolCall.arguments) {
		return toolCall;
	}
	return {
		...toolCall,
		arguments: preparedArguments as Record<string, any>,
	};
}

两个细节:

  • 返回同一个引用时直接透传,不做无谓的拷贝。
  • 改写后的参数只用于校验和执行,转录里保留模型原始发的内容。可以实测:
text
转录中的 toolCall 参数: {"filePath":"a.txt"}
工具收到的参数:        {"path":"a.txt"}

这个差别在审计和排查时很有用——你能看到模型实际发了什么。

prepareArguments 是兼容层,不是校验层。它应该只做形状规整(改名、补默认值),不要在这里做业务规则判断——业务规则应该在 execute() 里,或者干脆用 schema 表达。

把接收动作定义成工具

创建 src/submit-result.ts:

typescript
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"
import { createConnection } from "./connection.js"
import { sourceCode } from "./lesson.js"
import { analysisSchema, type FunctionAnalysis, validateAnalysis } from "./schema.js"

export function createSubmissionTool(onAccepted: (value: FunctionAnalysis) => void) {
  let accepted = false
  const tool: AgentTool<typeof analysisSchema> = {
    name: "submit_analysis",
    label: "提交函数分析",
    description: "提交当前练习函数的最终分析结果,每次任务只接受一份有效结果。",
    parameters: analysisSchema,
    constrainedSampling: { type: "json_schema", strict: "prefer" },
    async execute(_id, params) {
      if (accepted) {
        throw new Error("已经接受过一份结果")
      }
      const analysis = validateAnalysis(params)
      onAccepted(analysis)
      accepted = true
      return {
        content: [{ type: "text", text: "结果已通过校验并接收。" }],
        details: { accepted: true },
        terminate: true,
      }
    },
  }
  return tool
}

export async function runSubmission() {
  const { models, model } = createConnection()
  let submitted: FunctionAnalysis | undefined
  const tool = createSubmissionTool((value) => {
    submitted = value
  })
  const agent = new Agent({
    initialState: {
      systemPrompt: "必须调用 submit_analysis 提交最终结果,不以说明文字代替提交。",
      model,
      tools: [tool],
    },
    streamFn: models.streamSimple.bind(models),
    toolExecution: "sequential",
  })
  let turns = 0
  agent.finishTurn = async () => {
    turns += 1
    if (turns >= 4) {
      return { action: "end" }
    }
  }
  const timer = setTimeout(() => agent.abort(), 30_000)
  try {
    await agent.prompt(
      `请调用 submit_analysis 提交函数分析,包含源码原文证据,不确定的空输入行为用 null。代码:\n${sourceCode}`,
    )
    const last = agent.state.messages.findLast((item) => item.role === "assistant")
    if (
      last?.role === "assistant" &&
      (last.stopReason === "error" || last.stopReason === "aborted")
    ) {
      throw new Error(last.errorMessage || last.stopReason)
    }
    if (!submitted) {
      throw new Error("没有收到通过校验的工具提交结果")
    }
    console.log(JSON.stringify(submitted, null, 2))
  } finally {
    clearTimeout(timer)
    agent.abort()
    await agent.waitForIdle()
  }
}

再创建入口 src/submit-main.ts:

typescript
import { runSubmission } from "./submit-result.js"

await runSubmission()

运行:

bash
node --env-file=.env --import tsx src/submit-main.ts

预期得到的是提交执行器接受的对象,不是助手最终说明里恰好出现的一段 JSON。如果模型只回答文字,没有调用工具,程序会报告没有收到提交。

参数校验失败时,模型会看到什么

校验失败时,prepareToolCall() 把异常消息变成错误工具结果。模型下一轮就能读到它,并有机会自我纠正。

实际的消息长这样:

text
Validation failed for tool "submit_analysis":
  - evidence: expected string, got number

Received arguments:
{
  "name": "first",
  "parameters": ["items"],
  "summary": "返回数组的第一个元素",
  "emptyInputBehavior": "返回 undefined",
  "evidence": 42
}

这个错误结果作为 toolResult 消息进入上下文,isError: true。模型看到自己提交的东西被拒了,通常会改成合法形式重试。

这条自动纠正回路是工具相比纯文本解析的最大优势。纯文本方案里,模型不知道哪里错了,只能盲猜;工具方案里,错误消息本身就是结构化的反馈。

用模拟 Provider 可以稳定复现这个回路——第一次发错类型,第二次发对:

text
--- assistant toolUse
   block: toolCall add({"a":1,"b":"x"})
--- toolResult
   content: [{"type":"text","text":"Validation failed for tool \"add\":\n  - b: expected number, got string\n\nReceived arguments:\n{\n  \"a\": 1,\n  \"b\": \"x\"\n}"}] | isError: true
--- assistant toolUse
   block: toolCall add({"a":1,"b":2})
--- toolResult
   content: [{"type":"text","text":"3"}] | isError: false
--- assistant stop

三层约束各自负责什么

parameters 描述工具输入;constrainedSampling 请求服务端尽可能约束生成;validateAnalysis() 在执行时检查字段和源码引用。这三者不能互相替代。

层位置失败时
constrainedSampling
服务端生成时
服务端可能退回普通生成,或请求失败
parameters schema 校验
执行前,本地
错误工具结果,模型可重试
validateAnalysis()
execute() 内,业务层
抛异常,同样变成错误工具结果

本例 strict: 'prefer' 表示支持时优先使用严格约束,不支持时可能退回普通工具调用。换成 require 则需要服务和具体模型确实支持,否则请求可能失败。不能因为代码中写了 strict 就声称所有模型都会严格生成。

Agent Core 的参数处理包含类型转换:validateToolArguments() 用的是 compile(schema, { coerce: true }),会把数字字符串转数字、单值转数组、丢弃可选属性里的 null。所以执行器拿到的参数不一定等于模型原始 JSON。本例在 execute() 里再次进行严格 Schema 与业务检查,约束的是最终接收的数据。如果业务要求原始类型绝不能转换,还要另行核验参数预处理策略。

为什么接收后可以结束

数据在 onAccepted() 回调中交给宿主。本例只保存在内存,不写入数据库。terminate: true 请求运行时在该批工具结果都同意终止时,不再自动发起后续模型请求。

终止规则的实现是"全部同意"而不是"任一同意":

typescript
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
	return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}

所以批次里只要有一个工具结果没有设 terminate,整批就不会提前终止。

因此,最后的助手消息可能仍是工具调用消息,不能只寻找一段最终自然语言回答来判断成功。本例以通过校验的数据为接收依据,并额外检查运行错误状态。

轮次和时间都有上限。模型反复提交无效内容时,程序最终退出,而不是无限修复。取消或错误也不会被当成正常完成。

防止同一任务重复接收

提交工具每次创建时保存一个 accepted 状态,成功后拒绝第二次提交。工具按顺序执行,避免同一批次并发修改这个标志。

这只是一次内存任务内的去重,不是数据库事务或跨进程幂等保障。把结果存入外部系统时,还需要任务身份、幂等键和提交失败恢复,不能照搬一个布尔变量当作完整事务。

本例回调成功返回后才设置已接收标志。若实际回调会产生外部副作用,再抛出异常,就可能出现"外部已写入但程序认为失败"的状态,需要业务自己处理。

检查是否真正提交

正常样本应该调用执行器并返回经过校验的对象。无效证据不能进入回调;已经接收后再次调用应该失败;没有工具调用的文本回答不能代替提交。

这些路径已经用模拟 Provider 检查。真实服务是否选择工具、是否执行严格约束,仍需根据模型验证。下一篇讨论另一个方向:工具已经执行后,怎样返回给程序使用的数据。

参考资料