最近在开发 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负责最终审核。
问题马上就出现了。
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
真实 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 的返回值可以继续携带敏感数据传播。
演示视频:
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…