调整工具设计,让智能体更容易正确执行
通过对照实验调整描述、参数与反馈,同时验证任务完成和不应调用的场景。
调整工具设计,让智能体更容易正确执行
一个工具在手动测试中正常,模型却可能选错、漏参或反复调用。工具对开发者清楚,不意味着对模型也足够明确;评估需要同时看执行能力与模型使用行为。
这篇沿用只读检查工具,只比较描述时保持实现不变。如果同时改了 Schema、返回格式和提示,即使成功率变化,也难判断哪项动作起了作用。
独立示例位于仓库 columns/coding-agent/examples/evaluation-optimization/,固定 Pi 相关依赖为 1.5.0。先在该目录执行 npm install,下文命令也从此处运行。离线样本不需要密钥;真实模型入口另行配置,不会由测试自动触发。
先理解工具真正能做什么
工具只接受三个教学文件名,返回 file、status、names。文件不存在作为 missing 业务结果返回,而不是伪造读取成功。工具正则也会受注释或字符串影响,不能提供完整语法分析或安全判断。
完整教学运行器如下,其中工具、执行轨迹、输入快照与运行预算都在同一处:
import { readFile } from "node:fs/promises"
import { Agent, type StreamFn } from "@earendil-works/pi-agent-core"
import { type Api, contentText, type Model } from "@earendil-works/pi-ai"
import { obj, str } from "@earendil-works/pi-schema"
import { type Artifact, parseReport, type ToolEvidence } from "./grading.js"
import type { Report, Task } from "./tasks.js"
import type { variantConfig } from "./variants.js"
export class RunBudgetError extends Error {}
const allowed = ["sample.ts", "arrow.ts", "missing.ts"]
export async function inspectFile(file: string): Promise<Report> {
if (!allowed.includes(file)) {
throw new Error("file 必须是 sample.ts、arrow.ts 或 missing.ts")
}
try {
const source = await readFile(new URL(`../fixtures/${file}`, import.meta.url), "utf8")
if (Buffer.byteLength(source) > 65536) {
throw new Error("教学文件超过上限")
}
return {
file,
status: "ok",
names: [...source.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)].map((match) => match[1]),
}
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
return { file, status: "missing", names: [] }
}
throw error
}
}
async function snapshot() {
return Promise.all(
["sample.ts", "arrow.ts"].map((file) =>
readFile(new URL(`../fixtures/${file}`, import.meta.url), "utf8"),
),
)
}
export async function runTask(
task: Task,
config: ReturnType<typeof variantConfig>,
model: Model<Api>,
streamFn: StreamFn,
timeoutMs = 30000,
): Promise<{
artifact: Artifact
durationMs: number
input: number | null
output: number | null
}> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error("timeoutMs 必须为正数")
}
const before = await snapshot()
const tools: ToolEvidence[] = []
let turns = 0
let calls = 0
let limitReason = ""
const agent = new Agent({
streamFn: (selected, context, options) => {
if (limitReason) {
throw new RunBudgetError(limitReason)
}
return streamFn(selected, context, options)
},
initialState: {
model,
thinkingLevel: "off",
systemPrompt: config.systemPrompt,
tools: [
{
name: "inspect_file",
label: "检查教学文件",
description: config.toolDescription,
parameters: obj({ file: str() }),
async execute(_id, params, signal) {
signal?.throwIfAborted()
if (
typeof params !== "object" ||
params === null ||
!("file" in params) ||
typeof params.file !== "string"
) {
throw new Error("file 必须是字符串")
}
const result = await inspectFile(params.file)
signal?.throwIfAborted()
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result }
},
},
],
},
})
const argumentsById = new Map<string, string>()
const unsubscribe = agent.subscribe((event) => {
if (event.type === "turn_start" && ++turns > 4) {
limitReason = "超过四回合教学预算"
agent.abort()
}
if (event.type === "tool_execution_start") {
const args = event.args as Record<string, unknown>
argumentsById.set(event.toolCallId, typeof args?.file === "string" ? args.file : "")
if (++calls > 6) {
limitReason = "超过六次工具调用教学预算"
agent.abort()
}
}
if (event.type === "tool_execution_end") {
tools.push({
name: event.toolName,
file: argumentsById.get(event.toolCallId) ?? "",
result: parseReport(contentText(event.result.content)),
isError: event.isError,
})
}
})
const timer = setTimeout(() => {
limitReason = "超出本地等待时间"
agent.abort()
}, timeoutMs)
const started = performance.now()
try {
await agent.prompt(config.context + task.prompt)
if (limitReason) {
throw new RunBudgetError(limitReason)
}
const assistant = agent.state.messages.filter((message) => message.role === "assistant")
if (
assistant.some(
(message) => message.stopReason === "error" || message.stopReason === "aborted",
)
) {
throw new Error("模型请求未正常完成,不能当任务低分处理")
}
const last = assistant.at(-1)
const after = await snapshot()
return {
artifact: {
response: last ? contentText(last.content) : "",
tools,
unchanged: JSON.stringify(before) === JSON.stringify(after),
},
durationMs: performance.now() - started,
input:
assistant.length === 0
? null
: assistant.reduce((sum, message) => sum + message.usage.input, 0),
output:
assistant.length === 0
? null
: assistant.reduce((sum, message) => sum + message.usage.output, 0),
}
} finally {
clearTimeout(timer)
unsubscribe()
}
}Agent 只能调用显式注册的工具,没有默认文件写入或 shell。每个任务创建新 Agent;前后比较两份输入,检查教学文件有没有变化。这些限制服务于可复验,不等于完整操作系统隔离。
描述说清选择条件与结果含义
在 variantConfig('tool', ...) 中,baseline 只简述字段,candidate 补充文件白名单、命名声明范围、missing 和能力限制;其他配置保持不变。
运行 npm run eval:live -- tool 1 会真实比较两组。增加说明是否值得,需要看任务完成、误调用、参数错误与消耗,不能只看工具调用次数增多。no-tool 任务专门检查不需要调用时能否克制。
参数和错误反馈也需要独立实验
若模型总把文件内容填进 file 参数,可以实验更明确的命名、枚举或描述。Schema 变更应同时核对宿主校验和工具运行逻辑,不让文档说允许、实现却拒绝。
错误反馈应告诉模型如何修正,并保持可检查结构;堆栈和模糊报错既可能泄漏信息,也不一定帮助恢复。对不存在的文件,本例给出 missing,但权限拒绝、读取损坏或未知异常仍应保留为错误。
更短的结果不一定更好
截断返回可以节省上下文,也可能删掉决定答案的证据。比较精简版本时,同时检查遗漏率和后续补查行为;只有 Token 下降不能说明整体效率改善。
工具设计的一手实践讨论了描述、参数、返回内容与错误信息的迭代。这里把它们转成可分别验证的假设,不承诺相同收益,也不默认工具越少越好。
本例没有执行真实模型比较,因此只报告 runner、工具和评分代码的验证。实际选择与恢复能力仍需在线实验,模拟流不会替你发现描述歧义。
依据与版本
官方本地快照为 8efd314fa,evals 包标记 1.3.0;本系列示例另按发布包 1.5.0 核验。官方 evals 是 private 工作区包,教程使用自定义教学协议,不冒充稳定公共评测 SDK。