Agent 看不到 Token,Tool 又必须使用 Token,怎么办?

108 阅读10分钟

最近在开发 Workrun 的 Agent Workflow 时,我遇到了一个挺有意思的问题:

我希望 Agent 能调用需要敏感数据的 Tool,但又不希望 LLM 本身看到这些敏感数据。

举个例子。

假设有这样一个退款 Workflow:

用户提交退款申请
        │
        ▼
    extractor
        │
        ▼
    crm-agent
        │
        ▼
     reviewer

Workflow 的初始输入里包含:

{
  "orderId": "20260903001",
  "customerEmail": "alice@example.com",
  "crmToken": "Bearer eyJhbGciOi..."
}

其中:

  • extractor 负责提取退款信息;
  • crm-agent 需要调用 CRM Tool 查询客户;
  • reviewer 负责最终审核。
Workrun 退款工作流全景与配置首页

问题马上就出现了。

CRM Tool 调用时确实需要:

customerEmail
crmToken

但这两个值其实没有必要进入 LLM Context。

尤其是 Token。

如果把整个 Workflow State 一股脑塞进 Agent Prompt,那么:

Workflow State
      ↓
LLM Context
      ↓
Tool Call

意味着模型天然拥有了所有数据的读取权限。

这不是我想要的结果。

我真正希望的是:

                 ┌──── LLM
                 │
Workflow State ──┤
                 │
                 └──── Tool

LLM 和 Tool 看到的应该是两份不同的数据视图。

于是我开始重新设计 Workrun 的 Workflow State。


01. 不要把 Workflow State 当成一份共享 JSON

很多 Workflow 实现最简单的方式,是维护一个共享 State:

{
  "orderId": "20260903001",
  "customerEmail": "alice@example.com",
  "crmToken": "Bearer eyJhbGciOi...",
  "summary": "用户申请退款",
  "internalScore": 82
}

然后节点执行时,从里面读取自己需要的数据。

这样实现当然简单。

但随着 Workflow 里开始出现 Agent、Tool、MCP、本地 Process 等不同执行单元,一个问题会越来越明显:

谁到底应该看到哪些数据?

例如:

extractor
    ↓
crm-agent
    ↓
reviewer

crm-agent 需要知道退款摘要。

CRM Tool 需要真实邮箱和 Token。

而 reviewer 可能只需要:

{
  "summary": "用户申请订单 20260903001 的退款"
}

它既不需要 Token,也不应该知道内部评分和客户联系方式。

所以在 Workrun 里,我没有继续把 Workflow State 设计成所有节点共享的一份平铺 JSON,而是引入了 Scope State。


02. Scope State:节点输出默认属于自己

基本思路其实很简单:

节点产生的数据默认私有,需要共享的数据显式发布。

例如 extractor 输出:

{
  "summary": "用户申请订单 20260903001 的退款",
  "customer": {
    "email": "[SENSITIVE REDACTED]",
    "reason": "重复下单"
  },
  "internalScore": 82
}

这里我只希望:

summary

能够被后续普通节点读取。

于是可以显式配置:

{
  "state": {
    "globalKeys": ["summary"]
  }
}

最终:

extractor namespace
├── summary
├── customer
└── internalScore
        │
        │ publish
        ▼
Global State
└── summary

这样 reviewer 默认只能拿到:

{
  "summary": "用户申请订单 20260903001 的退款"
}

而不会因为处于同一个 Workflow,就自动获得:

customer.email
internalScore
crmToken

这解决了第一个问题:

Workflow 中的数据不应该天然对所有节点可见。

但还有一个更麻烦的问题。


03. Agent 不应该看到 Token,但 Tool 必须使用 Token

假设 crm-agent 有一个 Tool:

@tool(
    name="mock_crm_lookup",
    description="查询模拟客户的退款资格。",
)
def mock_crm_lookup(email: str, token: str) -> dict[str, object]:
    if not token:
        raise ValueError("缺少 CRM Token")

    return {
        "status": "active",
        "customerName": "Alice",
        "email": email,
        "refundEligible": True,
    }

调用它需要:

{
  "email": "alice@example.com",
  "token": "Bearer eyJhbGciOi..."
}

最简单的实现当然是:

Raw State
   ↓
Prompt
   ↓
LLM
   ↓
Tool Arguments

但这样 Token 必然经过 LLM。

所以我最后采用了另外一种方式:

                    ┌──────────────┐
Raw State ─────────►│ Tool Runtime │
                    └──────────────┘
       │
       │ Redaction
       ▼
Visible State
       │
       ▼
      LLM

也就是:

模型看到的数据和 Tool 可以使用的数据彻底分开。


04. Visible State 和 Raw State

Workflow Input 进入运行时以后,我把它分成两种视图。

Raw State

运行时内部保存的真实数据:

{
  "orderId": "20260903001",
  "customerEmail": "alice@example.com",
  "crmToken": "Bearer eyJhbGciOi..."
}

Visible State

提供给 Agent、UI 等场景的数据:

{
  "orderId": "20260903001",
  "customerEmail": "[EMAIL REDACTED]",
  "crmToken": "[SENSITIVE REDACTED]"
}

所以 Agent Prompt 从一开始拿到的就是:

Visible State

而不是先把 Raw State 给模型,再通过 Prompt 告诉它:

不要泄露 crmToken。

这两种方式在我看来有本质区别。

Prompt 约束解决的是:“你看到了,但请不要使用。”

数据隔离解决的是:“你根本看不到。”

对于 Token、API Key、用户隐私数据,我更倾向于后者。


05. 但是 Tool 怎么获得真实值?

这也是整个设计里我觉得最关键的一部分。

如果 Agent 看到的是:

{
  "customerEmail": "[EMAIL REDACTED]",
  "crmToken": "[SENSITIVE REDACTED]"
}

那么模型生成的 Tool Call 很可能也是:

{
  "email": "[EMAIL REDACTED]",
  "token": "[SENSITIVE REDACTED]"
}

显然不能直接拿这个请求 CRM。

所以 Workrun 增加了一层 Tool State Binding。

例如:

{
  "toolStateBindings": [
    {
      "toolId": "crm.lookup",
      "argumentPath": "email",
      "statePath": "customerEmail"
    },
    {
      "toolId": "crm.lookup",
      "argumentPath": "token",
      "statePath": "crmToken"
    }
  ]
}

它表达的不是:

把 Raw State 给 Agent。

而是:

当这个 Tool 真正执行时,把经过授权的 State 字段绑定到指定 Tool Argument。

执行过程变成:

LLM
 │
 │ 生成 Tool Call
 ▼
{
  email: "[EMAIL REDACTED]",
  token: "[SENSITIVE REDACTED]"
}
 │
 ▼
Managed Tool
 │
 ├── 检查当前节点是否有 Raw State 权限
 │
 ├── 检查 Tool State Binding
 │
 └── 从 Raw State 获取指定字段
 │
 ▼
{
  email: "alice@example.com",
  token: "Bearer eyJhbGciOi..."
}
 │
 ▼
CRM API

image.png

真实 Token 始终保留在 Raw State 中,只在 Tool 即将执行时按授权绑定到对应参数,不进入 LLM Context。

整个过程中,它都不需要进入 LLM Context。


06. 为什么不能直接把 Raw State 给 Tool?

做到这里还有另一个问题。

既然 Tool 有权限读取 Raw State,那是不是直接:

tool.execute(raw_state)

就行了?

我最后没有这么设计。

因为这样实际上只是把权限问题从 Agent 转移到了 Tool。

一个 Tool 原本只需要:

customerEmail
crmToken

结果却拿到了整个:

Workflow Raw State

权限还是太大。

所以 Tool State Binding 必须精确到字段路径:

Tool Argument      State Path

email        ←     customerEmail
token        ←     crmToken

而不是:

Tool ← Entire Raw State

换句话说:

授权对象不是“这个 Tool 可以访问 Raw State”,而应该尽量接近“这个 Tool 的这个参数可以读取这个 State Path”。

这有点类似最小权限原则。


07. Tool 拿到数据以后,泄露问题还没有结束

做到这里其实仍然不安全。

因为:

LLM 看不到 Token

并不代表:

整个 Workflow 不会泄露 Token

例如 CRM Tool 返回:

{
  "customerName": "Alice",
  "email": "alice@example.com",
  "ip": "10.0.0.8"
}

如果 Tool Result 又直接写回:

Agent Context
Workflow State
Logs
UI

敏感数据还是绕了一圈回到了模型。

所以还需要第二层机制:

Guardrails。


08. Guardrails 应该存在于数据边界,而不是只存在于 Prompt

目前我在 Workrun 里主要考虑三个位置。

User Input
    │
    ▼
Input Guardrail
    │
    ▼
   LLM
    │
    ├──── Tool Call
    │        │
    │        ▼
    │   Tool Guardrail
    │        │
    │        ▼
    │      Tool
    │        │
    └────────┘
         │
         ▼
Output Guardrail
         │
         ▼
  State / UI / Logs

Input Guardrail

普通 PII 可以进行脱敏,例如:

alice@example.com
        ↓
[EMAIL REDACTED]

但对于明显的认证凭证,我现在更倾向于直接阻断。

例如:

Bearer Token
API Key
Private Key

因为用户把 Token 直接粘贴进 Prompt,很多时候本身就是误操作。

与其:

脱敏 → 继续让 Agent 执行 → Agent 发现缺少 Token

不如直接告诉用户:

这里不应该输入认证凭证。

09. Tool Result 同样需要 Guardrail

Tool 是整个 Agent 系统里一个很容易被忽略的数据入口。

例如:

CRM
 │
 ▼
Tool Result
 │
 ▼
Agent

CRM 返回:

{
  "customerName": "Alice",
  "email": "alice@example.com",
  "ip": "10.0.0.8"
}

在重新进入 Agent Context 前,应该再次经过过滤:

{
  "customerName": "Alice",
  "email": "[EMAIL REDACTED]",
  "ip": "[IP ADDRESS REDACTED]"
}

于是完整的数据路径变成:

Raw State
    │
    │ Tool State Binding
    ▼
   Tool
    │
    │ Raw Result
    ▼
Output Guardrail
    │
    ▼
Visible Result
    │
    ├── Agent Context
    ├── Workflow State
    ├── UI
    └── Logs

这里最重要的一点是:

Tool 能使用敏感数据,不代表 Tool 的返回值可以继续携带敏感数据传播。

演示视频:

演示.gif


10. Streaming 是另一个容易漏掉的地方

实现 Guardrail 的时候,我还碰到了一个比较实际的问题:

流式输出。

假设模型输出一个 Token:

sk-123456789

如果它被拆成:

Chunk 1: "sk-"
Chunk 2: "12345"
Chunk 3: "6789"

那么对每个 Chunk 单独跑正则:

detect(chunk)

很可能什么都检测不到。

等所有 Chunk 拼起来时,Token 已经显示在 UI 上了。

目前 Workrun 在 Rust 后端(agent.rs) 对需要经过输出 Guardrail 的 Agent Message 进行 Chunk 汇聚与递归过滤。因为在 Rust 纯内存层处理汇聚和正则校验速度极快(通常小于 2ms),这样既封堵了流式泄露,又几乎不产生感官上的延迟。

SSE Chunk
    │
    ▼
Backend Buffer
    │
    ▼
Complete Message
    │
    ▼
Output Guardrail
    │
    ▼
UI

这当然会牺牲一部分真正的逐 Token Streaming 体验。

但这也是做安全边界时必须面对的 trade-off:

Streaming latency 和完整内容检测之间怎么取舍?

我目前更倾向于先保证敏感数据不会因为 Chunk Boundary 绕过检测。


11. 最后形成的数据模型

经过这一轮设计之后,我现在对 Agent Workflow State 的理解大概变成了:

                   Workflow Raw State
                          │
              ┌───────────┴───────────┐
              │                       │
        Redaction                Authorization
              │                       │
              ▼                       ▼
       Visible State            Tool Binding
              │                       │
              ▼                       ▼
            Agent                    Tool
              │                       │
              └───────────┬───────────┘
                          │
                    Output Guardrail
                          │
                          ▼
                  Visible State / UI

这里其实有两个不同维度的问题。

第一个是:

Scope

哪个节点可以读取哪些数据?

第二个是:

Visibility

同一份数据,对于 LLM 和 Tool 应该以什么形式存在?

Scope State 解决前者。

Visible/Raw State + Tool State Binding 解决后者。

Guardrails 则负责在这些边界之外再增加一层兜底。


12. 这套方案仍然有很多问题没有解决

做到这里,我反而觉得 Agent Workflow 的数据安全问题比一开始想象得复杂很多。

例如现在还有几个我正在继续考虑的问题:

Tool Result 是否需要字段级 Taint Tracking?

如果一个 Tool 使用过敏感输入:

crmToken → Tool → result

是否应该自动认为某些 Result Field 也可能具有敏感属性?


MCP Tool 应该拥有怎样的数据权限?

本地 Tool 相对容易控制。

但如果是第三方 MCP Server:

Workflow
    ↓
MCP Client
    ↓
Remote MCP Server

Raw State 的授权边界应该放在哪里?


Checkpoint 怎么处理?

如果 Workflow 支持:

Pause
Resume
Retry
Checkpoint

那么 Raw State 最终很可能需要持久化。

这时候问题又从:

LLM 能不能看到 Token

变成:

Token 能不能安全地落盘?

Debugging 和安全之间怎么平衡?

开发 Workflow 时大家天然希望看到:

Input
Output
Tool Arguments
Tool Result
State

但越完整的 Debug 信息,潜在的数据泄露面也越大。

这也是我后面准备继续解决的问题。


最后

这次做 Workrun 的 Scope State 和 Guardrails,让我越来越觉得:

Agent Workflow 的安全问题,不能只依赖:

“在 Prompt 里告诉模型不要泄露。”

更可靠的方式应该是从 Runtime 层就控制:

模型能看到什么
      ↓
节点能读取什么
      ↓
Tool 能使用什么
      ↓
结果能够传播什么

尤其是:

如果一份数据模型本来就不需要看到,那最好的保护方式可能不是告诉它“不要使用”,而是从一开始就不要把数据交给它。

Workrun 目前仍然在开发中,这套设计也还在继续调整。

如果你也在做 Agent / Workflow / MCP / Tool Calling,我比较想知道一个问题:

你们现在是怎么处理“Tool 需要敏感数据,但 LLM 不应该看到敏感数据”这个问题的?

👉 Workrun GitHub 项目地址:github.com/1111mp/work…