1. 为什么工具调用需要一套“规矩”
1.1 从一次线上事故说起
去年冬天,我负责的一个内部 Agent 平台上线第三天就出了状况。一个负责整理会议纪要的 Agent,在调用“发送邮件”工具时,把本该发给项目组的周报,误发到了全公司两千多人的大群里。事后复盘发现,问题不在模型本身,而在于工具调用的参数校验环节形同虚设——模型把收件人字段理解成了“所有相关人员”,而平台没有任何机制去拦截这个明显越界的参数。
这件事让我意识到一个被很多人忽略的事实:AI Agent 的能力上限,往往不取决于模型有多聪明,而取决于工具调用的边界有多清晰。Dogwood 就是在这个背景下进入我视野的。它不是一个具体的 Agent 应用,而是一套给工具调用“立规矩”的工程框架。你可以把它理解成 Agent 世界的交通法规——它不负责造车,但规定了什么车能上路、走哪条道、超速了怎么罚。
Dogwood 的核心价值在于,它把工具调用从“模型自由发挥”变成了“在约束下执行”。这套约束体系围绕三个维度展开:权限边界(谁能调用什么)、参数契约(调用时传什么)、执行审计(调用后留什么痕)。这三个维度对应到工程实现上,就是 Cedar 策略引擎、MCP 协议适配层和调用链追踪模块的协同工作。
1.2 谁需要认真对待工具调用治理
如果你只是在自己电脑上跑个 demo,让 Agent 查查天气、算算数学题,那 Dogwood 这套东西确实有点杀鸡用牛刀。但只要你满足以下任意一条,工具调用的规矩就不是可选项而是必选项:
- Agent 能访问生产环境的数据库或 API
- 多个用户共享同一套 Agent 基础设施
- 工具调用涉及资金、隐私数据或对外发送操作
- 你需要向合规部门证明“AI 不会乱来”
我见过太多团队在 Agent 原型阶段一路狂飙,等到要上生产时才回头补权限和审计,结果发现整个调用链路的架构都得推倒重来。Dogwood 的思路是把治理能力做进调用链路本身,而不是作为外挂的中间件。这个设计选择后面会详细展开,先记住一个结论:治理逻辑离调用点越近,拦截越及时,排查越容易。
2. Dogwood 的整体架构与核心设计思路
2.1 三层拦截:把规矩立在调用发生之前
Dogwood 的架构可以用一句话概括:在模型和真实工具之间,插入一个可编程的策略执行层。这个执行层不是简单的代理转发,而是包含三个串联的拦截阶段。
第一阶段是意图解析。当模型输出一个工具调用请求时,Dogwood 不会直接把请求转发给工具,而是先解析出结构化的调用意图:调用的工具名、传入的参数、发起调用的 Agent 身份、当前会话的上下文标签。这一步的关键在于,它把模型输出的自然语言或半结构化 JSON,转换成了内部统一的调用描述对象。
第二阶段是策略裁决。拿着调用描述对象,Dogwood 会去查询 Cedar 策略引擎。Cedar 是 AWS 开源的策略语言,专门为细粒度权限控制设计。你可以用 Cedar 写这样的规则:“允许数据分析 Agent 调用 query_database 工具,但前提是查询语句中不包含 DELETE 或 DROP 关键字,且单次返回行数不超过 1000。”这条规则会在调用真正发生前被评估,不通过就直接拒绝,模型会收到一个明确的拒绝原因。
第三阶段是执行与审计。策略通过后,调用才会被转发到真实的工具端点。执行过程中,Dogwood 会记录完整的调用链:谁在什么时间、什么会话里、调用了什么工具、传了什么参数、返回了什么结果、耗时多少。这些审计日志不是简单堆砌,而是按照调用链 ID 关联,方便后续追溯。
注意:这三个阶段是串联的,任何一步失败都会阻断后续流程。这意味着策略引擎的可用性直接决定了工具调用的可用性,所以 Cedar 策略的评估必须足够快,Dogwood 在这方面做了不少优化,后面会提到。
2.2 为什么选 Cedar 而不是自己写权限逻辑
我最初也想过,权限控制嘛,不就是 if-else 判断一下 Agent 身份和工具名的组合?但实际写起来很快就发现,这种硬编码的方式在 Agent 场景下会迅速失控。
假设你有 5 个 Agent、20 个工具,最粗粒度的权限矩阵就是 100 个布尔值。但现实远比这复杂:同一个工具,不同 Agent 能传的参数范围不同;同一个 Agent,在不同会话上下文里权限不同;甚至同一个调用,参数值本身会触发不同的策略分支。用 if-else 写,代码会变成一团乱麻,而且每次调整权限都要改代码、重新部署。
Cedar 的优势在于策略与代码分离。权限规则用声明式的 Cedar 语言编写,存储在独立的策略仓库里。调整权限时只需要更新策略文件,不需要动 Dogwood 的核心代码。更重要的是,Cedar 支持形式化验证,你可以用数学方法证明“不存在任何一条策略允许 Agent A 删除数据库记录”。这种可证明的安全性,是手写 if-else 永远达不到的。
Cedar 策略的基本结构是这样的:
permit( principal == Agent::"data-analyst", action == Action::"invokeTool", resource == Tool::"query_database" ) when { context.query_type == "read_only" && context.max_rows <= 1000 };这段策略的意思是:允许>permit( principal, action == Action::"invokeTool", resource == Tool::"read_file" ) when { !context.path.contains("..") && context.path.startsWith("/safe/directory/") };
这里有个坑:Cedar 的字符串操作是大小写敏感的。如果模型输出的路径是 “/Safe/Directory/”,上面的策略就会拒绝。所以我在 descriptor 构造阶段会做一次路径规范化,统一转成小写并解析掉 “.” 和 “..”。
模式三:用 forbid 策略做硬性禁止。permit 和 forbid 同时存在时,forbid 优先级更高。这个特性适合用来表达“无论如何都不允许”的规则。比如:
forbid( principal, action == Action::"invokeTool", resource == Tool::"delete_database" );这条策略没有任何条件,意味着任何 Agent 在任何情况下都不能调用 delete_database 工具。这种硬禁止比在 permit 里写复杂条件要清晰得多,也更容易审计。
常见陷阱:策略中的数值比较。Cedar 的数值类型是有限的,它不支持浮点数比较。如果你的工具参数里有浮点数,比如温度值 36.5,在策略里直接写 context.temperature > 36.5 会报错。解决方案是在 descriptor 构造时把浮点数转成整数(乘以精度倍数),或者用字符串比较配合正则表达式。我一般选择前者,因为整数比较更可靠。
3.3 审计日志的结构化设计
审计日志最怕的就是“记了一堆但查不到”。Dogwood 的审计模块在设计上强调可查询性,每条日志记录都包含以下结构化字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| trace_id | string | 调用链唯一标识,贯穿整个调用生命周期 |
| timestamp | int64 | 毫秒级时间戳 |
| agent_id | string | 发起调用的 Agent 标识 |
| session_id | string | 会话标识 |
| tool_name | string | 被调用的工具名 |
| tool_version | string | 工具版本 |
| parameters | json | 调用参数快照 |
| policy_decision | enum | 策略裁决结果:PERMIT/DENY |
| deny_reason | string | 拒绝原因(仅 DENY 时存在) |
| execution_status | enum | 执行状态:SUCCESS/FAILURE/TIMEOUT |
| execution_duration_ms | int | 执行耗时 |
| result_summary | string | 返回结果摘要(截断敏感信息) |
这张表看起来简单,但每个字段的设计都有讲究。trace_id 我用的是 UUID v7,它包含时间戳信息,按 trace_id 排序就相当于按时间排序,省去了额外的时间索引。parameters 字段存的是完整参数快照,但会对敏感字段做脱敏处理——比如密码类参数只记录 “***”,不记录明文。
result_summary 字段的截断策略也值得一说。我最初把完整返回值都记下来,结果日志体积爆炸,而且有些返回值里包含用户隐私数据。后来改成只记录返回值的结构摘要:如果是列表,记录长度和前三个元素的类型;如果是对象,记录字段名列表。这样既能判断调用是否正常返回,又不会泄露数据。
提示:审计日志的存储建议用列式数据库,比如 ClickHouse 或 Parquet 文件。按 trace_id 和 timestamp 做分区,查询效率比行式数据库高一个数量级。我实测过,十亿条日志的按时间范围查询,列式存储能在秒级返回,行式数据库要几十秒。
4. 完整实操:从零搭建一个带治理的 Agent 工具调用链路
4.1 环境准备与依赖安装
Dogwood 本身是一个 Rust 实现的服务,但它的策略引擎和 MCP 适配层可以独立使用。我下面演示的搭建过程基于 Rust 生态,如果你用 Python 或 Java,思路是一样的,只是具体库不同。
先装 Rust 工具链,这个不用多说。然后创建一个新的 Cargo 项目:
cargo new dogwood-demo cd dogwood-demo在 Cargo.toml 里加入核心依赖:
[dependencies] cedar-policy = "3.0" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v7"] }cedar-policy 是策略引擎,tokio 提供异步运行时,serde 处理序列化,uuid 生成 trace_id。这些版本号是我写这篇文章时用的,你实际安装时可以用最新稳定版。
接下来定义核心数据结构。先定义 InvocationDescriptor:
#[derive(Debug, Clone, Serialize, Deserialize)] pub struct InvocationDescriptor { pub trace_id: String, pub agent_id: String, pub session_id: String, pub tool_name: String, pub tool_version: String, pub parameters: serde_json::Value, pub context_tags: HashMap<String, String>, }这个结构体对应前面说的调用描述对象。parameters 用 serde_json::Value 是为了灵活容纳各种工具参数,实际使用时会在策略评估前做类型校验。
4.2 策略引擎的初始化与评估
Cedar 策略引擎的初始化分两步:加载策略集和构造评估请求。
加载策略集:
use cedar_policy::{PolicySet, Policy}; let policy_src = r#" permit( principal == Agent::"data-analyst", action == Action::"invokeTool", resource == Tool::"query_database" ) when { context.max_rows <= 1000 }; "#; let policy = Policy::parse(None, policy_src).unwrap(); let mut policy_set = PolicySet::new(); policy_set.add(policy).unwrap();构造评估请求时,需要把 InvocationDescriptor 转换成 Cedar 的 Request 格式。principal 是 Agent 实体,action 固定为 invokeTool,resource 是 Tool 实体,context 里放参数和上下文标签。
use cedar_policy::{Request, EntityUid, Context}; let principal = EntityUid::from_str(&format!("Agent::\"{}\"", desc.agent_id)).unwrap(); let action = EntityUid::from_str("Action::\"invokeTool\"").unwrap(); let resource = EntityUid::from_str(&format!("Tool::\"{}\"", desc.tool_name)).unwrap(); let context = Context::from_json_value(desc.parameters.clone(), None).unwrap(); let request = Request::new(principal, action, resource, context, None).unwrap(); let decision = policy_set.is_authorized(&request, &entities);decision 的结果是 Permit 或 Deny。如果是 Deny,Dogwood 会从 Cedar 的诊断信息里提取出拒绝原因,返回给调用方。
这里有个性能优化的点:策略集可以缓存。Cedar 的 PolicySet 在加载后是不可变的,可以安全地在多个请求间共享。我实测过,缓存策略集后,单次策略评估的耗时从毫秒级降到了微秒级。对于高并发场景,这个优化很关键。
4.3 MCP 工具注册与调用转发
MCP 工具的注册需要提供工具描述文件,通常是一个 JSON schema。Dogwood 读取这个 schema 后,会自动生成工具的参数校验逻辑和调用接口。
一个典型的 MCP 工具描述长这样:
{ "name": "query_database", "version": "1.2.0", "description": "执行只读 SQL 查询", "inputSchema": { "type": "object", "properties": { "sql": { "type": "string" }, "max_rows": { "type": "integer", "default": 100 } }, "required": ["sql"] } }Dogwood 在注册这个工具时,会做两件事:一是把 inputSchema 存下来用于参数校验,二是生成一个工具端点配置,指定实际执行查询的服务地址。
调用转发的过程是这样的:策略裁决通过后,Dogwood 从 descriptor 里取出 parameters,按照 inputSchema 做一次校验(确保参数类型和必填项都符合),然后通过 HTTP 或 gRPC 转发到工具端点。转发时会带上 trace_id,方便工具端也记录调用链。
async fn forward_to_tool(desc: &InvocationDescriptor, endpoint: &str) -> Result<ToolResponse> { let client = reqwest::Client::new(); let resp = client .post(endpoint) .header("X-Trace-Id", &desc.trace_id) .json(&desc.parameters) .send() .await?; let tool_resp: ToolResponse = resp.json().await?; Ok(tool_resp) }这段代码里,X-Trace-Id 头是关键。它让工具端也能把这次调用关联到同一个 trace 上,排查问题时可以端到端地看完整链路。
4.4 审计日志的写入与查询
审计日志的写入我用的是异步 channel + 批量落盘的方式。每次调用完成后,把日志记录发到一个 mpsc channel,后台任务每积累 1000 条或每 5 秒批量写入一次存储。这样避免了每次调用都同步写磁盘带来的延迟。
let (tx, mut rx) = tokio::sync::mpsc::channel(10000); tokio::spawn(async move { let mut buffer = Vec::with_capacity(1000); let mut interval = tokio::time::interval(Duration::from_secs(5)); loop { tokio::select! { Some(log) = rx.recv() => { buffer.push(log); if buffer.len() >= 1000 { flush_logs(&buffer).await; buffer.clear(); } } _ = interval.tick() => { if !buffer.is_empty() { flush_logs(&buffer).await; buffer.clear(); } } } } });查询方面,我建议按 trace_id 建索引,同时按 timestamp 做分区。如果日志量特别大,可以考虑用对象存储 + 查询引擎的方案,比如把日志写成 Parquet 文件存到 S3 兼容存储,然后用 DuckDB 或 Trino 做查询。这个方案的成本比专用日志服务低很多,查询性能也够用。
5. 常见问题与排查技巧实录
5.1 策略不生效的排查路径
策略写了但没生效,这是最常遇到的问题。我总结了一个排查顺序,按这个顺序走基本能定位到原因。
第一步:确认策略是否被加载。Cedar 的 PolicySet 在加载策略时如果解析失败,会返回错误。但有些实现会静默忽略解析失败的策略,导致你以为加载了实际没有。我的做法是在加载后打印策略数量,和预期对比。
第二步:确认 principal 和 resource 的实体 ID 是否匹配。Cedar 的实体 ID 是大小写敏感的,Agent::"Data-Analyst" 和 Agent::"data-analyst" 是两个不同的实体。我踩过这个坑,策略里写的是小写,但 descriptor 里传的是大写,结果策略一直不匹配。
第三步:检查 context 里的字段名。Cedar 策略里引用的 context 字段名必须和 descriptor 里 parameters 的键名完全一致。如果工具参数是 max_rows,策略里写 context.maxRows 就会评估失败。Dogwood 在评估前会做一次字段名映射,但映射规则要配置正确。
第四步:用 Cedar 的诊断信息。Cedar 在评估失败时会返回诊断信息,包含哪些策略被评估了、为什么没有匹配。这个信息在调试时非常有用,建议在开发环境把诊断信息完整打印出来。
5.2 工具调用超时的处理策略
Agent 场景下的工具调用超时比普通 API 调用更复杂,因为模型可能已经基于“调用会成功”的假设继续生成了后续内容。Dogwood 的处理策略是超时即拒绝,并通知模型重新规划。
具体实现上,每个工具调用都有一个超时时间,默认 30 秒,可以在工具描述里覆盖。超时后,Dogwood 会中断调用,返回一个 TIMEOUT 状态的响应给模型。模型收到这个响应后,可以选择重试、换一个工具、或者告知用户操作失败。
这里有个经验:超时时间不要设得太短。我最初设了 5 秒,结果很多正常的数据库查询都被中断了。后来改成 30 秒,误杀率大幅下降。对于确实需要快速失败的工具,可以在工具描述里单独设短超时。
5.3 高频调用的限流与降级
当多个 Agent 并发调用同一个工具时,工具端可能扛不住。Dogwood 在策略层支持速率限制,用 Cedar 的 context 传入调用频率计数,策略里判断是否超过阈值。
但更优雅的做法是在 Dogwood 内部实现令牌桶限流。每个工具一个令牌桶,调用前先取令牌,取不到就排队或拒绝。我用的配置是:普通工具每秒 100 个令牌,敏感工具每秒 10 个令牌,突发容量是速率的 2 倍。
降级策略方面,当工具端不可用时,Dogwood 可以返回一个缓存的最近成功结果(如果工具支持缓存),或者返回一个明确的“服务暂不可用”错误让模型处理。我倾向于后者,因为缓存结果可能导致模型基于过期数据做决策,风险更大。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 策略一直 Deny | 实体 ID 大小写不匹配 | 打印 principal 和 resource 的 EntityUid | 统一大小写规范 |
| 策略评估报错 | context 字段类型不匹配 | 检查 Cedar 诊断信息 | 在 descriptor 构造时做类型归一化 |
| 工具调用无响应 | 工具端点不可达 | 检查网络连通性和端点配置 | 配置健康检查与自动摘除 |
| 审计日志缺失 | channel 满了被丢弃 | 监控 channel 积压量 | 增大 channel 容量或加快落盘 |
| 并发调用被限流 | 令牌桶容量不足 | 查看限流指标 | 调整令牌桶参数或扩容工具端 |
| 模型收到拒绝后卡住 | 拒绝原因不明确 | 检查返回给模型的错误信息 | 提供结构化的拒绝原因和替代建议 |
提示:这张表里的“模型收到拒绝后卡住”是我遇到的最棘手的问题之一。模型收到一个模糊的“权限不足”错误后,往往会反复重试同一个调用,陷入死循环。解决方案是在拒绝响应里明确告诉模型“为什么被拒绝”以及“可以尝试什么替代方案”。比如“当前 Agent 无权调用 delete_database,如需删除数据请使用 soft_delete 工具”。这样模型就能调整策略而不是死磕。
6. 一些踩坑之后的个人体会
Dogwood 这套东西我从去年开始在自己的项目里用,中间踩了不少坑,也积累了一些文档里不会写的经验。
策略的粒度要渐进式细化。一开始不要试图写出完美的策略,先写粗粒度的 permit 和 forbid,让调用能跑起来。然后根据审计日志里实际发生的调用,逐步收紧策略。我现在的策略文件是经过十几轮迭代才稳定下来的,第一版只有三条规则。
审计日志的存储成本要提前算。一条审计日志大约 1KB,如果每天有 100 万次调用,一天就是 1GB,一年 365GB。这个量级用对象存储很便宜,但如果用商业日志服务,成本会很高。提前规划好存储方案,避免后期迁移的麻烦。
MCP 工具的版本管理不能省。我吃过亏,一个工具从 1.0 升级到 2.0,参数结构变了,但策略里没有按版本区分,导致旧策略在新工具上产生了意料之外的放行。现在我的策略里都会明确指定 tool_version 范围,工具升级时必须同步更新策略。
限流阈值要留余量。我最初把令牌桶设得刚好够用,结果一次流量小高峰就把工具端打挂了。后来改成按峰值流量的 1.5 倍设置令牌桶容量,同时给工具端配置自动扩容,才稳定下来。
这个内容后续还可以往两个方向扩展:一是把策略引擎做成独立的 sidecar,让不同语言的 Agent 都能接入;二是把审计日志和调用链追踪打通,实现从模型输出到工具返回的全链路可视化。这两个方向我都在探索中,有进展再分享。