上一篇讲的是用户不在场时事情怎么按时发生。这一篇讲另一个「不在场」的问题:事情发生的时候,你也不在场。
Agent 平台迟早要面对这样的追问:用户说「我没让它删东西,为什么没了」,管理员说「这把 Key 昨天半夜在调什么工具」,安全说「证明一下这个账号没干坏事」。回答不了这三个问题,前面两篇搭的一切都不敢真的交给 Agent 用。
我们的答案是两条独立的日志流,分工从一开始就分开:
| 审计日志(AuditLog) | 调用日志(AgentCallLog) | |
|---|---|---|
| 回答 | 谁授权了什么变更 | Agent 实际做了什么 |
| 覆盖 | 所有成功的写操作(mutation) | 每次 MCP / Skill 工具调用 |
| 消费者 | 人(管理员、审计、追责) | 机器 + 人(排障、统计、集成向导) |
| 记法 | 一行人类可读的摘要 | input / resultStatus / 耗时 / 错误 |
| 保留 | 长期 | 90 天滚动清理 |
一个是「授权面」的证据,一个是「执行面」的证据。合并成一张表看起来省事,但两者的写入量差两个数量级、保留期差一个数量级、消费者完全不同——合表意味着用审计的保留策略养调用日志的写入量,或者反过来。
审计:写在 middleware 里,不写在业务里
审计最容易做坏的方式,是在每个业务函数里手动调用 writeAuditLog。第一个月没问题,第三个月新增的 mutation 开始漏记——因为「记得写审计」依赖开发者自觉,而自觉是最不可靠的基础设施。
所以审计收口在 tRPC 的 middleware 里,挂在 protectedProcedure 上:
const auditMiddleware = t.middleware(
async ({ next, ctx, path, type, getRawInput }) => {
const result = await next();
if (type === "mutation" && result.ok) {
writeAuditLog(ctx, path, result, await getRawInput()).catch(
/* 仅记错误日志 */
);
}
return result;
}
);
三个设计决定值得展开。
线画在 type === "mutation" 上。 查询不审计。有人会问:查询也可能泄露敏感数据,为什么不记?因为审计回答的是「谁改变了什么」——变更才有追责的对象。查询的监控属于访问日志的职责,用调用日志和数据库慢查询日志覆盖。把线画窄一点,审计的信号密度才高:翻审计日志的人看到的全是真实变更,没有一千条「查询列表成功」垫在里面。
只记成功的。 result.ok 才写。失败的尝试有价值吗?有,但那是安全监控的事(暴力尝试、越权探测),记录在调用日志的错误状态里。审计日志保持一个干净的语义:每一条都对应一次真实发生的状态变更。
fire-and-forget。 审计写入失败只打错误日志,不影响业务响应。这是个可以被挑战的决定——审计写丢了怎么办?我们的权衡:审计是事后证据,不是事前防线;权限校验才是防线(见第一篇),审计丢了损失的是「事后能查」,而让它阻塞业务损失的是「现在就不能用」。在内部平台的画像下,前者是可以接受的残余风险。但如果你的审计有合规刚性(SOX 那类),这里应该反过来选。
约定式推断:审计不要求业务声明自己
middleware 拿到的是 procedurePath(如 apiKey.revoke)和 input / result,action、实体类型、实体 ID、人类可读描述,全部推断出来:
apiKey.revoke→ actionREVOKE、entityTypeApiKey——路由器前缀映射实体,动词后缀映射动作;- 少数不规则的路由(删除单个文件版本)用一张小小的 override 表覆盖;
- 实体 ID 优先从返回值取,取不到按
keyId / id / assetId / userId…的优先级从 input 里捞; - 描述生成人话:「撤销了 API Key (a1b2c3d4)」「将用户角色变更为『admin』(e5f6…)」。
这套约定的收益是新增 mutation 默认被审计,漏记的方式从「忘了写」变成「故意绕过 middleware」——后者在 code review 里显眼得多。代价是推断不完美:偶尔 entityType 推不准、描述不够具体,靠 override 表和特判函数逐步打磨。这是典型的「覆盖率高但精度 95%」换「覆盖率依赖自觉但精度 100%」——对审计这种广度优先的场景,前者是对的。
actor 不接受自报
审计记录的 actor 结构值得单独说。API Key 发起的操作记两样:创建者是谁(actorId)和用的是哪把 Key(apiKeyId)。缺一不可——只有前者,分不清同一人的三把 Key 哪把泄露了;只有后者,Key 换主人后追责就断了线。
这套结构在 REST 侧遇到过一个考验:外部系统要往平台提交审计事件(POST /api/v1/audit-logs),请求体里自然带着 actor 字段。审计的 actor 绝不能由请求方自报——否则任何持 Key 者都能把操作栽给别人。所以提交入口有一层防伪造校验,规则很直白:
- Session 认证不得声称自己是 Key;
- API Key 认证不得伪装成 Session 用户;
actorId必须与当前身份的创建者一致,apiKeyId必须与当前 Key 一致。
允许提交的只有「描述自己实际做的事」,身份部分一律以服务端解析为准。这和能力目录零参数是同一条原则的两次应用:凡是安全相关的字段,不由客户端声明,从凭据推导。
调用日志:执行面的全部证据
AgentCallLog 记每次工具调用:渠道(MCP / Skill / 调度)、工具名、入参、结果状态、结果数据、耗时、错误信息,外加完整的请求元数据(requestId、IP、UA、actor 双记录)。
里面有个字段名叫 subjectUserId,注释写着:实际使用人;个人动态必须只按此字段查询,不能使用 API Key 创建者。
这是第一篇「用户绑定 Key vs 系统 Key」区分在这里的回声:一把 Key 的创建者和它的实际使用人通常相同,但系统 Key(userId 为 null)的「使用人」要落到触发那次调用的具体场景上。所有「给我看我的动态」类查询都按 subjectUserId 过滤——如果哪处代码图省事按创建者查,系统 Key 的调用就会串进某个用户的动态里。这种字段级的语义约束,靠注释和测试双保险。
不记什么:三个脱敏开关
调用日志默认全记 input 和 result——排障时你希望看到 Agent 到底传了什么、拿到了什么。但有三类东西刻意不记,实现为三个开关:
skipResultData:整段结果不落库。 能力目录那两个协议工具开着它。协议全文动辄几 KB,进了日志就成了薄壳系列反复强调的那个问题:日志是协议的第二个缓存副本——既是旧版本的长存地(Agent 和排障的人可能读到过期协议),也是泄露面。协议的正确获取路径只有一条:每次会话重新拉取。
redactKeys:结果里的指定字段替换为 <redacted>。 最典型的对象是预签名下载 URL。注释里的推理链值得抄下来:这些 URL 在数据库日志里存续的时间 = URL 的有效期,期间可被任何能读调用日志的人复用。一个本来 15 分钟过期的签名链接,因为进了日志、日志保留 90 天,实际「有效期」变成 90 天。脱敏不是洁癖,是把凭证的生命周期从日志的保留期里解耦出来。
redactInputKeys:入参里的指定字段脱敏。 用户让 Agent 记的日记正文、报告草稿内容——调用日志需要知道「调了记录工具、成功」,不需要存正文本身。这类数据有它们自己的存储和权限边界,复制进日志等于绕过了那个边界。
外围还有一道通用的尺寸防线:所有进日志的 JSON 超过 4096 字符截断并追加 [truncated] 标记——防止一次异常的超大结果把日志表撑爆。
整套脱敏建立在一条铁律上:日志路径永远不能影响主响应。写入是 fire-and-forget,函数从不抛错(失败进结构化错误日志和健康计数器),JSON 解析失败就原样返回。排障设施自己变成故障源,是最难堪的一类事故。
90 天:日志的生老病死
调用日志每条写入时带 expiredAt = createdAt + 90 天,清理函数按它物理删除。清理跑在哪?上一篇的调度心跳里,作为 tick 的维护段,和业务 Worker 各自独立加锁。
保留期 90 天是个折中:太短,「上周三那个奇怪的行为」还没来得及被报告就没了;太长,调用日志的量级(每次工具调用一条)会把存储和脱敏责任都变成慢性负担。审计日志则长期保留——量级小(只有成功 mutation)、价值随时间不衰减(追责、复盘)。
聚合的降级:ready / partial / unavailable
日志的最后一跳是消费。仪表盘按卡片聚合数据(API Key 状态、最近调用、任务、自动化……),每张卡片独立的 loader。任何一个 loader 挂了,页面不是 500,也不是空白,而是返回三态聚合:
const status =
failed === attempted
? "unavailable" // 全挂:明确不可用
: failed > 0
? "partial" // 部分挂:半开着
: "ready"; // 全部正常
配合 failedCardTypes 列表,前端能精确渲染「这几张卡片暂时取不到数据,其余正常」。
这个设计的点在于诚实的粒度。二元思维下聚合接口只有成功失败两态,一个次要卡片挂掉就把整个仪表盘拖成错误页——用户得到的信号是「平台挂了」,而事实是「六分之一的数据源挂了」。partial 状态把故障的半径如实传达出来。排障的人也受益:哪几张卡片固定 unavailable,基本就锁定了是哪个下游的问题。
小结
- 审计和调用日志是两条流:授权面的证据要窄而久,执行面的证据要全而短——合并会让两者的保留策略互相绑架;
- 审计写在 middleware 而非业务里,「漏记」从常态变成显眼的例外;线画在成功 mutation 上,保住每条记录的语义纯度;
- 安全相关字段永远服务端推导:actor 不自报、实际使用人按
subjectUserId查; - 不记什么和记什么同样重要:协议全文不进日志(防第二副本),预签名 URL 必须脱敏(凭证有效期 ≠ 日志保留期);
- 消费端用三态聚合诚实传达故障半径,partial 比全页错误更接近真相。
到这里,「生产级 Agent 平台」系列的主体三篇完成:身份(Agent 是谁)→ 调度(用户不在场时)→ 可观测(你不在场时)。三个维度拼起来,正好是一句完整的话:让 Agent 能进门、能持续做事、并且做的每件事都经得起追问。
如果还有第四篇,会讲最后一段路:这套东西怎么落进企业内网——堡垒机挡在数据库前面、共享库里 public schema 的表名雷区、构建镜像的加速。那是工程琐记的密度,看心情和工作量决定写不写。
系列现有三篇。如果你在搭自己的 Agent 平台,建议按这个顺序检查:身份收口了吗?调度解耦了吗?出了事能回答吗?