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

调整工具设计,让智能体更容易正确执行

通过对照实验调整描述、参数与反馈,同时验证任务完成和不应调用的场景。

调整工具设计,让智能体更容易正确执行

一个工具在手动测试中正常,模型却可能选错、漏参或反复调用。工具对开发者清楚,不意味着对模型也足够明确;评估需要同时看执行能力与模型使用行为。

这篇沿用只读检查工具,只比较描述时保持实现不变。如果同时改了 Schema、返回格式和提示,即使成功率变化,也难判断哪项动作起了作用。

独立示例位于仓库 columns/coding-agent/examples/evaluation-optimization/,固定 Pi 相关依赖为 1.5.0。先在该目录执行 npm install,下文命令也从此处运行。离线样本不需要密钥;真实模型入口另行配置,不会由测试自动触发。

先理解工具真正能做什么

工具只接受三个教学文件名,返回 file、status、names。文件不存在作为 missing 业务结果返回,而不是伪造读取成功。工具正则也会受注释或字符串影响,不能提供完整语法分析或安全判断。

完整教学运行器如下,其中工具、执行轨迹、输入快照与运行预算都在同一处:

ts
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。