模型说“执行”,系统不能立刻照做

模型输出一句“删除临时文件”,距离操作系统真正删除文件,中间应该隔着一条清楚的工程边界。

这条边界要检查工具是否存在、参数是否合法、当前任务是否允许、操作是否需要审批、应该在哪个环境执行,以及结果要怎样返回给模型。它还要隐藏凭据、处理超时、记录审计信息,并保证同一工具在不同 Agent 中可以有不同权限。

这个边界就是 Harness 的第四个核心部分:工具网关

模型负责表达意图,工具网关负责把意图转换为受控的现实操作。如果把 Agent 比作一家公司,模型是提出工作请求的员工,工具网关则是统一办事大厅:每项业务都有表单、权限、办理流程和回执。

本文是《把 Harness 讲清楚:Agent 背后的运行系统》八个核心部分的第四篇。

工具不是一个普通函数

在代码里,工具最终可能确实是一个函数。但对 Agent 来说,一个合格的工具至少包含这些信息:

{
  "name": "read_file",
  "description": "读取工作区内一个文本文件的指定行范围",
  "input_schema": {
    "path": "string",
    "offset": "positive integer",
    "limit": "integer between 1 and 500"
  },
  "execution_mode": "parallel",
  "risk": "read_only",
  "timeout_ms": 10000,
  "output": "带行号的文本窗口,并标记是否截断"
}

名称和描述帮助模型选择工具,schema 限制参数形状,执行模式帮助调度器判断并发,风险标签交给策略层,输出约定让后续处理保持稳定。

只有函数实现而没有这些契约,模型就只能靠猜。

一个工具调用经过哪些阶段

模型生成工具名与参数
          ↓
查找当前 Agent 可见的工具
          ↓
解析并校验参数
          ↓
应用任务范围、权限和审批策略
          ↓
选择执行环境与并发模式
          ↓
执行工具,处理取消、超时和异常
          ↓
规范化结果并过滤敏感信息
          ↓
记录审计事件
          ↓
把适量结果返回模型和用户界面

任意一步失败都应该返回明确语义。例如 UNKNOWN_TOOLINVALID_ARGUMENTSPERMISSION_DENIEDTIMEOUTRESULT_TOO_LARGE,比统一返回“工具调用失败”更有用。

工具设计首先要窄

下面两个工具都能执行命令:

工具 A:run_anything(command: string)
工具 B:run_tests(target: string, timeout_seconds: integer)

工具 A 灵活,但模型需要自己拼接 Shell 字符串,权限范围巨大,也难以判断副作用。工具 B 能力较窄,却更容易描述、校验、审计和授权。

不是所有场景都必须禁用通用终端,但高频业务动作最好提供语义明确的窄工具。一个好的工具通常具备:

  • 名称说明动作,不依赖隐含上下文;
  • 参数少而清楚,枚举优于自由文本;
  • 一次调用只承担一个主要职责;
  • 读操作和写操作分开;
  • 错误告诉调用方下一步可以怎么做;
  • 返回结构稳定,并明确是否截断。

参数校验必须在模型之外

提示词可以告诉模型“路径必须在工作区”,但真正执行前仍然要由代码验证。

def validate_read_file(args, workspace):
    path = resolve_path(workspace, args["path"])

    if not path.is_inside(workspace):
        raise ToolError("PATH_OUTSIDE_WORKSPACE")

    if args["limit"] < 1 or args["limit"] > 500:
        raise ToolError("INVALID_LIMIT")

    if not path.is_file():
        raise ToolError("FILE_NOT_FOUND")

    return path

模型生成结构化参数能够减少错误,却不能取代服务端校验。模型输出始终是不可信输入。

只暴露当前任务需要的工具

如果系统注册了 200 个工具,不代表每次请求都要全部提供给模型。

过多工具有三类成本:

  1. 每个 schema 都占用上下文;
  2. 相似工具增加误选概率;
  3. 不必要的高风险能力扩大攻击面。

工具网关可以根据任务契约、Agent 身份和当前阶段生成可见集合:

def visible_tools(agent, task, step):
    tools = registry.for_scope(agent.scope)
    tools = tools.allowed_by(task.allowed_actions)
    tools = tools.allowed_by(agent.identity)
    tools = tools.relevant_to(step.kind)
    return tools

例如分析阶段只提供读取和搜索,确认需要修改后才开放写文件,只有用户明确要求发布时才开放外部发布工具。

凭据不能进入模型上下文

模型可能需要调用带认证的 API,但它通常不需要知道令牌本身。

正确流程是:

模型调用 create_issue({ title, body })
              ↓
工具网关识别当前用户与目标服务
              ↓
凭据服务在执行侧注入访问令牌
              ↓
向外部 API 发起请求
              ↓
过滤响应中的敏感字段后返回结果

令牌不应出现在工具描述、参数、日志、报错或模型可见文本中。否则上下文记录、遥测和第三方模型请求都可能扩大泄露范围。

读写分类与并发调度

多个只读调用通常可以并行,例如同时读取三个互不相关的文件。写操作则需要更谨慎。

一种实用分类是:

模式 适用工具 调度方式
Parallel 纯读取、无共享可变状态 进入有界并发池
Exclusive 写文件、执行命令、外部写入 作为顺序屏障单独运行

并发安全应该“默认关闭”。只有工具明确定义为安全,分类器明确返回允许时才并行。不确定、异常或未声明都回退到独占模式。

即使工具主体并行,结果也最好按照模型原始调用顺序提交。这样历史稳定、回放确定,也避免先完成的后置上下文改变后续结果顺序。

返回结果需要两种形态

程序和模型对结果的需求不同。

数据库查询的规范结果可能包含 5000 行结构化 JSON,程序可以继续过滤;模型可能只需要总数、前 20 条和下一页游标。用户界面又可能希望展示一张表格。

因此可以拆成:

  • 规范值:完整、类型稳定,供程序和后续工具使用;
  • 模型内容:有长度控制的文本或结构化摘要;
  • 展示元数据:供 UI 渲染表格、差异或终端卡片。

这种分离能避免为了模型上下文而破坏程序需要的数据,也不会为了保留完整数据而把上下文撑爆。

MCP 在工具网关中的位置

Model Context Protocol 提供了外部工具发现和调用的标准方式。Harness 可以把 MCP Server 暴露的工具映射进自己的注册表,例如:

MCP 原始工具:search
服务器名称:docs
Harness 可见名称:mcp__docs__search

但“来自 MCP”不等于可以绕过本地策略。映射后的工具仍应经过参数验证、作用域限制、审批、调度、超时和结果过滤。

MCP 解决的是连接协议,工具网关解决的是整个 Agent 产品中的准入和执行治理。两者互补,不是替代关系。

工具执行器伪代码

async def execute_tool(agent, task, call):
    tool = registry.resolve(agent.scope, call.name)
    if tool is None:
        return error("UNKNOWN_TOOL")

    args = tool.schema.parse(call.arguments)

    decision = policy.authorize(
        identity=agent.identity,
        task=task,
        tool=tool,
        arguments=args,
    )

    if decision.requires_approval:
        decision = await approval.ask(decision.prompt)

    if not decision.allowed:
        return error("PERMISSION_DENIED", decision.reason)

    try:
        value = await with_timeout(
            tool.execute(args, credentials.for_tool(tool)),
            tool.timeout,
        )
        return tool.present(tool.redact(value))
    except Timeout:
        return error("TIMEOUT")

注意:超时后是否可以重试,要由工具的幂等性和副作用语义决定,不能在这个函数里统一重放。

常见的失败方式

工具描述过于相似

searchfindlookup 都写着“查找信息”,模型很难稳定选择。名称和适用边界应有明显区别。

允许工具自行读取全局凭据

凭据分散在环境变量和日志里,难以审计和轮换。应该由统一凭据服务按调用注入。

把工具异常原样返回模型

堆栈可能泄露路径、SQL、令牌或内部结构。内部日志可以详细,模型可见错误应安全且可行动。

写工具没有幂等键

发布、付款和创建工单在网络超时后重试,可能生成重复结果。外部写操作应支持稳定请求键或先查询状态。

把超时声明当成已经执行

schema 里写了 timeout_ms,不代表底层一定会取消。运行时必须真正传播信号并执行截止策略。

工具网关检查清单

  • 工具名称、描述和适用场景是否清楚?
  • 参数是否有严格 schema 和服务端校验?
  • 是否只向当前 Agent 暴露必要工具?
  • 读、写和破坏性操作是否明确分类?
  • 凭据是否只在执行侧注入?
  • 工具是否支持取消、超时和结构化错误?
  • 并发是否默认保守,并设置上限?
  • 结果是否区分规范值、模型内容和 UI 展示?
  • 是否标记截断、总量和完整数据位置?
  • 外部写入是否具备幂等或状态核验机制?

最后理解工具网关

工具让 Agent 拥有行动能力,工具网关让这种能力变得可控。

成熟的系统不会把模型输出直接接到操作系统和外部 API,而是在中间建立一条稳定边界:先理解调用,再验证参数和权限,选择正确环境执行,最后返回可审计的结果。

一句话总结:Agent 决定想做什么,工具网关决定这件事能不能做、在哪里做,以及怎样安全地做。