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

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

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

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

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

本文是《[把 Harness 讲清楚：Agent 背后的运行系统](https://www.ittt.cc/articles/15)》八个核心部分的第四篇。

## 工具不是一个普通函数

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

```json
{
  "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 限制参数形状，执行模式帮助调度器判断并发，风险标签交给策略层，输出约定让后续处理保持稳定。

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

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

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

任意一步失败都应该返回明确语义。例如 `UNKNOWN_TOOL`、`INVALID_ARGUMENTS`、`PERMISSION_DENIED`、`TIMEOUT` 和 `RESULT_TOO_LARGE`，比统一返回“工具调用失败”更有用。

## 工具设计首先要窄

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

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

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

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

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

## 参数校验必须在模型之外

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

```python
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 身份和当前阶段生成可见集合：

```python
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，但它通常不需要知道令牌本身。

正确流程是：

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

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

## 读写分类与并发调度

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

一种实用分类是：

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

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

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

## 返回结果需要两种形态

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

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

因此可以拆成：

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

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

## MCP 在工具网关中的位置

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

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

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

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

## 工具执行器伪代码

```python
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")
```

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

## 常见的失败方式

### 工具描述过于相似

`search`、`find`、`lookup` 都写着“查找信息”，模型很难稳定选择。名称和适用边界应有明显区别。

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

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

### 把工具异常原样返回模型

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

### 写工具没有幂等键

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

### 把超时声明当成已经执行

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

## 工具网关检查清单

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

## 最后理解工具网关

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

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

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