# 通过 API 响应最小化，让 AI 代理上下文更安全

AI 代理不需要复制它们接触到的每个对象。它们只需要足够的信息来作出下一步决定、执行操作并报告结果。当 API 将完整的客户记录、发票、工单、代码库设置或事故对象返回给只需要 ID 和状态的代理时，API 已经扩大了数据暴露问题的范围。

这个问题很容易被忽略，因为请求可能是只读的，经过身份验证，并通过 TLS 发送。但这些条件都不会改变接下来发生的事情。响应可能进入代理记录、工具追踪、模型请求、本地缓存、错误报告或人工审核队列。只要代理能读到它，就应该假定它已经进入代理上下文。

实际的解决办法是 API 响应最小化：为每个代理任务定义最小的有用响应，让这种响应易于请求，并把广泛数据访问设为需要人工审查的例外路径。这不是为了让 JSON 看起来更漂亮，而是为了减少个人、财务和运营数据在一次普通自动调用之后出现在其他位置的机会。

## 经过身份验证的读取仍可能暴露过多信息

读取权限可以限制写入，但不能限制复制、摘要、引用，或意外地把数据发送给其他工具。团队常常把代理称为“只读”，仿佛这样就解决了风险。它只解决了一类风险。

设想一个代理负责找出逾期发票并创建后续任务。它需要发票 ID、账户 ID、到期日、金额、货币和催收状态。传统的发票接口还可能返回账单地址和收货地址、税务标识符、支付处理商引用、明细描述、财务用户的内部备注，以及完整的支付历史。每个额外字段都会增加一条代理可能重复说出的信息，即使任务根本不需要它。

运营数据也有同样的问题。检查部署是否完成的任务可能需要服务名称、构建标识符、状态和失败类别。它很少需要一份包含主机名、内部地址、命令输出、事故评论或无关配置的完整环境导出。

人们经常混淆的区别很重要：授权回答调用方是否可以访问某个资源；最小化回答针对这项具体工作，调用方应该收到该资源的多少内容。一个拥有读取 `invoice:123` 权限的令牌，可能完全通过授权检查，却仍然收到不安全的发票表示。

IETF 的 RFC 9110 将表示描述为用于反映资源当前状态或期望状态的信息。它并没有要求每个资源只能有一种最大化表示。这为设计留下了空间。只要 API 对每种契约都有清晰说明，一个资源就可以提供摘要表示、运营表示和财务表示。

不要依赖“忽略个人数据”这样的提示词指令。提示词会影响行为，响应结构才控制暴露。如果接口发送了家庭住址，代理在决定是否忽略它之前就已经收到了它。

## 从代理必须完成的工作开始

安全的响应契约应该从代理必须作出的决定开始，而不是从现有数据库模型开始。用一句话写下任务，然后列出会改变操作的事实。其他内容都必须说明为什么应该存在。

例如，重试失败构建任务的代理可能只需要下面的响应：

```json
{
  "job_id": "job_4821",
  "state": "failed",
  "retryable": true,
  "failure_class": "transient_dependency",
  "attempts_remaining": 1
}
```

它不需要完整构建日志来判断是否允许重试。如果之后有人需要诊断信息，可以提供单独的接口，并设置更小的受众范围，同时要求明确说明获取理由。日志接口也应该支持有边界的范围，因为完整日志中出现令牌、客户输入、路径和配置片段的频率，往往比人们承认的更高。

在修改接口之前，先建立一张小型任务矩阵。它会迫使团队讨论那些原本容易含糊带过的问题：

| 代理任务 | 决策字段 | 操作字段 | 默认排除的字段 |
| --- | --- | --- | --- |
| 创建支持后续任务 | 工单 ID、优先级、类别 | 账户 ID、分派队列 | 消息正文、附件、内部备注 |
| 重试任务 | 任务 ID、状态、是否可重试 | 重试令牌或任务 ID | 完整日志、环境变量 |
| 标记逾期发票 | 发票 ID、到期日、金额、状态 | 账户 ID | 地址、税务数据、支付引用 |
| 检查服务健康状况 | 服务 ID、状态、错误类别 | 事故 ID | 主机详情、原始诊断信息 |

只有在某个字段会改变代理采取的分支、出现在操作请求中，或必须出现在面向用户的报告中时，它才属于响应。“以后可能有用”不是充分理由。正是这句话让列表接口不断增加字段，最后没人知道谁依赖其中任何一个字段。

这个练习还会暴露出一些应该计算而不是披露的字段。代理不需要读取工资记录来判断费用审批是否需要经理批准。返回 `approval_required: true` 即可。它不需要查看所有权限来判断部署能否继续。返回 `deployment_permitted: false` 和稳定的原因代码即可。

这不是通过隐蔽性来实现安全，而是通过有意设计的 API 契约，让调用方得到所需结果，同时不把底层记录交给它。

## 默认对象应该是摘要，而不是数据库行

最可靠的设计是让普通列表和查询调用默认返回安全摘要。详细表示应当明确请求、单独授权，并且很少使用。如果要求每个调用方都记住一个限制性查询选项，这种设计最终会失败，尤其是某个库添加了省略该选项的便捷方法之后。

客户摘要可以是这样：

```json
{
  "id": "cus_7f31",
  "display_name": "Northwind Parts",
  "account_state": "active",
  "open_invoice_count": 2,
  "support_tier": "standard"
}
```

不要因为客户行中恰好有 `email`、`phone`、街道地址、税务标识符、支付工具元数据或自由文本备注，就把它们返回。某些字段可能是计费应用所必需的，但它们不属于运营代理使用的摘要契约。

有两种可行模式。单独的摘要接口，例如 `GET /customers/{id}/summary`，直接明了，也容易审计。投影参数，例如 `GET /customers/{id}?view=summary`，在视图集合固定且有文档说明时也可以使用。这两种方式都好过一个返回所有内容、再要求每个客户端自行忽略无用字段的接口。

不要为面向代理的凭据提供通用的 `expand=*` 或 `include=all` 开关。它会在调试时变成最省事的路径，之后因为移除它感觉有风险，就一直留在生产环境中。如果确实需要详细表示，应以任务命名，例如 `view=collections`、`view=deployment_status` 或 `view=case_triage`。任务名称会促使团队进行设计审查，“全部”则不会。

有人会反对说，单独的视图会重复代码。它们确实会重复一些映射代码，但这点成本远小于调查工具记录中为何出现税号或内部事故备注。映射层也是记录数据归属、测试代理视图是否排除敏感列的地方。

## 字段选择必须使用允许列表，而不是解析器技巧

`fields` 参数可以有效缩减响应，但前提是服务器将它当作严格的允许列表。宽松的解析器会把便利功能变成数据提取接口。

下面的请求是合理的：

```text
GET /v1/invoices?state=overdue\u0026fields=id,account_id,due_date,amount,currency,collection_state\u0026limit=25
```

服务器应该只返回该接口和凭据允许的字段。如果调用方请求 `billing_address` 或 `payment_reference`，就应以清晰的错误拒绝请求。不要静默添加敏感字段，也不要接受 `customer.*` 这样的任意嵌套路径。

响应契约可以精确说明行为：

```json
{
  "error": {
    "code": "unsupported_field",
    "message": "Field 'payment_reference' is not available in the agent invoice view",
    "allowed_fields": [
      "id",
      "account_id",
      "due_date",
      "amount",
      "currency",
      "collection_state"
    ]
  }
}
```

错误本身也需要受到约束。绝不要包含被拒绝字段的值、附近记录数据、堆栈跟踪、来自其他服务的原始查询文本或数据库错误。错误正文经常意外变成第二个 API，尤其是在工程师为了加快事故处理而让错误信息变得过于详细时。

GraphQL 也需要同样仔细地审查。人们会认为客户端只能请求自己写出的字段，这确实有所帮助，但模式仍可能暴露敏感字段，嵌套关系可能使记录数量成倍增加，别名也可能让单个查询难以分析。设置深度和复杂度限制，在适合当前环境的情况下禁用或限制内省，并对字段而不只是顶层对象进行授权。更重要的是，为少数经过批准的任务创建代理专用模式或持久化查询。宽泛的模式加上一条礼貌的指令，并不能构成窄接口。

OWASP API Security Top 10 指出了对象属性级授权失效问题。它通常被描述为调用方读取了本不应访问的属性。代理场景还增加了另一种失败模式：调用方在技术上可能有权访问该属性，但任务并不需要它，也不应将它分发到模型上下文中。两项检查都要保留。先问“这个凭据可以读取它吗？”，再问“这项任务现在为什么需要它？”

## 分页控制数量，但筛选控制相关性

返回十条记录并不自动意味着响应很小。如果每条记录都包含大型嵌套对象或很长的文本字段，分页只是把泄露整齐地分成几页。

使用能够表达代理工作内容的筛选条件。催收代理应该查询处于指定状态和日期范围内的逾期发票，而不是列出所有发票后再在本地判断哪些相关。部署代理应该请求一个服务和当前版本，而不是查询每个环境后再从结果中搜索。

游标分页也需要谨慎设计响应结构。游标应该是不透明的，不应包含电子邮件地址、账户名称、未加密的筛选值，或暴露排序方式的内部数据库键。客户端会把游标放进日志和工单中，所以要把它视为会随数据传播的内容。

为代理凭据设置保守的页面上限。较小的限制不只是减少令牌使用量，还会提供一个暂停点，让代理先查看摘要，选择相关记录，再进行有针对性的后续调用。相比之下，仅仅因为任务以“调查这个客户”开头，就加载完整账户历史，风险更高。

不要把搜索接口与返回所有匹配详情的权限混为一谈。搜索通常应该返回结果卡片：稳定 ID、标签、状态，也可以加上匹配原因。调用方选定记录后，再获取有权限的详细视图。这种两次调用的模式不如大型结果对象方便，但能让敏感信息的传输变得可见且可审查。

## 自由文本和嵌套记录需要单独的边界

结构化字段比人类书写的文本更容易分类。自由文本字段会吸收姓名、电话号码、误粘贴的凭据、指控、健康信息、法律建议和内部意见。工单的 `description` 在模式审查中看起来可能无害，直到有人阅读一整周的真实工单。

对于自主工作流，应默认将评论、备注、描述、附件、日志和消息正文视为敏感内容。如果只需这些内容中的类别、简短的服务器生成分类或数量来选择操作，就返回这些摘要。例如，代理可能只需要 `has_customer_reply: true` 和 `latest_message_at`，而不是消息正文。

不要在检索任意文本后再要求模型进行脱敏。这种方法很受欢迎，因为它看起来可以保留一个宽泛的统一接口，但它会以两种方式失败。第一，原始内容在脱敏之前就已经进入代理上下文。第二，模型生成的脱敏结果具有概率性，部分姓名、账号或引用可能会留下。

如果任务确实需要文本，就为请求设置硬性边界。按 ID 获取一条消息，而不是完整线程。请求服务器强制执行的字符数上限。除非用户批准这次特定检索，否则不要返回附件内容。截断时要明确告诉客户端会收到什么，例如 `content_truncated: true`，这样代理不会臆造缺失的细节。

嵌套数据会造成更隐蔽的同类问题。包含 `customer`、`contacts`、`invoices`、`payments` 和 `events` 的响应，在应用代码中看起来可能只是一个对象，但从暴露角度看，它其实是一组相互独立的数据集。要求每种关系使用单独接口，或为每种关系提供明确且受允许列表控制的展开方式。然后测试最糟糕的普通查询，而不只是返回一条稀疏记录的顺利路径。

## 错误处理和可观测性可能重新制造泄露

团队常常先缩小成功响应，然后又把原始负载复制到调试日志、追踪属性、重试队列和异常报告中。数据只是换了位置，并没有减少暴露。

检查完整的调用路径。至少要查看代理工具封装、HTTP 客户端调试模式、请求记录器、分布式追踪配置、错误报告服务、任务队列、本地会话存储和支持工作流。那些声称“只记录元数据”的位置，应当直接测试，而不是凭信任通过。

在非生产环境中运行一条金丝雀记录。为绝不能进入代理上下文的字段填入醒目的虚假值，例如 `CANARY_BILLING_ADDRESS_927` 和 `CANARY_INTERNAL_NOTE_927`。执行真实的代理任务，然后在所有允许使用的日志和追踪存储中搜索这些字符串。对失败请求、超时和格式错误的响应也重复测试。只测试成功路径，会漏掉大多数意外的负载捕获。

有用的调用日志应该记录操作，而不是复制内容：

```json
{
  "time": "2025-03-08T14:03:12Z",
  "caller": "release-agent",
  "operation": "GET /v1/jobs/{id}/retry-status",
  "resource_id": "job_4821",
  "response_view": "retry_status",
  "field_set": ["job_id", "state", "retryable", "failure_class"],
  "result_count": 1,
  "outcome": "200"
}
```

只有在你自己的保留和访问规则允许的情况下，才记录标识符。在风险更高的系统中，可以改用带密钥的引用或短期关联 ID。如果原始值来自范围很小且容易猜测的集合，哈希本身也可能泄露信息，因此不要不加分析就把哈希称为脱敏。

在服务器边界清理对外错误消息。数据库驱动可能暴露失败的 SQL 片段，上游服务可能在错误封装中发送完整记录。你的 API 应将这些失败映射为稳定的公开代码，把详细诊断保存在受限存储中，并默认不在面向代理的错误中包含响应正文。

## 在代理网关中分离能力与披露

操作网关应该持有凭据并执行请求，但不能把该凭据可获得的任何响应都视为适合进入代理上下文。密钥隔离和响应最小化解决的是同一次调用中的不同问题。

Sallyport 会将 API 和 SSH 密钥保存在加密保险库中，并把操作结果返回给代理，而不是暴露密钥本身。这保护了凭据，但 API 所有者仍需判断结果中是否包含不必要的账户记录、命令输出或运营细节。

尽可能为每项代理任务提供命名请求模板。模板固定方法、主机、路径结构、允许的查询字段、页面上限和接受的响应视图。发布状态模板可以允许一个服务 ID，并返回简短的状态对象。它不应因为传递任意 URL 和任意 `fields` 表达式很容易，就接受这两者。

这正是宽泛代理思路会造成问题的地方。通用 HTTP 转发器在开发阶段可能有用，但它无法表达“检查这次部署”和“下载所有构建日志”之间的区别。应将意图放进可调用的操作中。当新任务需要更多数据时，要求修改 API 或新增模板。这个摩擦正是目的所在：必须有人说明为什么额外数据必须进入上下文。

对于例外情况，人工审批仍然有作用。如果代理需要一条支持消息的内容来解决工单，人员可以在看到目标和范围后，批准这一次具体调用。审批不应成为窄响应的常规替代品。人们在事故期间尤其容易快速批准熟悉的卡片，反复审批会让他们逐渐停止阅读。

## 将字段缺失作为契约的一部分进行测试

大多数 API 测试会断言预期字段存在。面向代理的 API 还需要测试禁止字段不存在，包括代码走备用路径时也一样。

为每个响应视图保留一项拒绝列表测试。使用真实的字段名，包括嵌套关系和自由文本。如果序列化过程后来通过 ORM 默认值、共享 DTO 或预加载关系添加了字段，测试就应该失败。

```python
forbidden = {
    "email",
    "phone",
    "billing_address",
    "tax_id",
    "payment_reference",
    "internal_note",
    "attachments",
}

body = get_invoice_agent_view("inv_1042")
assert forbidden.isdisjoint(body.keys())
assert "customer" not in body
assert "events" not in body
```

这个简单测试只能捕获顶层字段。还要增加遍历整个 JSON 树的序列化测试，并分别测试列表、搜索、错误和导出接口。最严重的泄露往往来自集合响应，因为有人为了少写几行代码，让它复用了完整详情序列化器。

契约测试还应断言响应大小边界。严格的字节上限不适合所有对象，但合理的上限可以在开发者添加无界文本字段或关系时发出提醒。测试数据中要包含长备注和许多子记录，否则测试会带来虚假的安全感。

审查变更时，直接问三个问题：哪个代理任务需要这个字段？哪个响应视图包含它？什么测试证明它在其他地方始终缺失？如果作者无法回答，就不要把字段合并到可被广泛调用的接口中。

## 让特殊详情检索可见且临时

有些工作确实需要敏感详情。欺诈审查、账户恢复、安全调查和复杂支持案件不可能完全依靠摘要完成。解决办法不是假装不存在这种需求，而是让详情检索明确、短期有效，并限制在确切记录范围内。

使用单独的接口或操作，接收稳定记录 ID 和声明的用途。只返回完成任务所需的最小切片，例如一项有争议的支付字段或一条选定的客户消息。不要因为同一个案件中存在一笔有争议的扣款，就授予完整账户导出的权限。

对于风险更高的检索，要求人员批准这一次具体调用，并记录调用方、用途、视图、记录引用和结果。将审计记录与敏感响应正文分开保存。你需要知道检索发生过，但不应因此又创建一份随手可得的信息副本。

成熟的 API 会让安全路径最容易使用。摘要视图应有清晰的名称、完善的文档和稳定的字段。广泛的详情接口则应让人明确感受到它承担了更多责任。如果代理反复需要某个敏感字段，不要把例外变成常态。重新审视任务设计，看看服务器端决定或经过脱敏的派生值是否已经足够。

第一次有价值的审计通常应从列表接口开始，而不是从大家已经害怕的接口开始。记录一个真实的代理任务，标出它使用的每个字段，再将这份列表与它收到的响应进行比较。未使用的部分，就是下一项 API 变更的起点。
