跳至正文
来两杯美式
返回

Agent 刚才干了什么:审计、调用日志与敏感数据豁免

By 来两杯美式
发布于

上一篇讲的是用户不在场时事情怎么按时发生。这一篇讲另一个「不在场」的问题:事情发生的时候,你也不在场

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、人类可读描述,全部推断出来:

这套约定的收益是新增 mutation 默认被审计,漏记的方式从「忘了写」变成「故意绕过 middleware」——后者在 code review 里显眼得多。代价是推断不完美:偶尔 entityType 推不准、描述不够具体,靠 override 表和特判函数逐步打磨。这是典型的「覆盖率高但精度 95%」换「覆盖率依赖自觉但精度 100%」——对审计这种广度优先的场景,前者是对的。

actor 不接受自报

审计记录的 actor 结构值得单独说。API Key 发起的操作记两样:创建者是谁actorId)和用的是哪把 KeyapiKeyId)。缺一不可——只有前者,分不清同一人的三把 Key 哪把泄露了;只有后者,Key 换主人后追责就断了线。

这套结构在 REST 侧遇到过一个考验:外部系统要往平台提交审计事件(POST /api/v1/audit-logs),请求体里自然带着 actor 字段。审计的 actor 绝不能由请求方自报——否则任何持 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,基本就锁定了是哪个下游的问题。

小结

  1. 审计和调用日志是两条流:授权面的证据要窄而久,执行面的证据要全而短——合并会让两者的保留策略互相绑架;
  2. 审计写在 middleware 而非业务里,「漏记」从常态变成显眼的例外;线画在成功 mutation 上,保住每条记录的语义纯度;
  3. 安全相关字段永远服务端推导:actor 不自报、实际使用人按 subjectUserId 查;
  4. 不记什么和记什么同样重要:协议全文不进日志(防第二副本),预签名 URL 必须脱敏(凭证有效期 ≠ 日志保留期);
  5. 消费端用三态聚合诚实传达故障半径,partial 比全页错误更接近真相。

到这里,「生产级 Agent 平台」系列的主体三篇完成:身份(Agent 是谁)→ 调度(用户不在场时)→ 可观测(你不在场时)。三个维度拼起来,正好是一句完整的话:让 Agent 能进门、能持续做事、并且做的每件事都经得起追问。

如果还有第四篇,会讲最后一段路:这套东西怎么落进企业内网——堡垒机挡在数据库前面、共享库里 public schema 的表名雷区、构建镜像的加速。那是工程琐记的密度,看心情和工作量决定写不写。

系列现有三篇。如果你在搭自己的 Agent 平台,建议按这个顺序检查:身份收口了吗?调度解耦了吗?出了事能回答吗?


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系

上一篇
AI 组织转型:为什么「全员用 AI」和「通用方案」都是陷阱
下一篇
定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层