1. 为什么“谁让 Agent 做了什么”突然成了头号难题
我接手维护一个内部 Agent 平台的时候,其实一开始没人提“审计”两个字。大家关心的是意图识别准不准、工具调用对不对、回复要不要人工审核。直到某天合规那边递过来一个问题:这个动作到底是谁让 Agent 去做的?是用户明确发起的,还是 Agent 自己“灵机一动”补的?当时所有人都愣住了——没人答得上来,因为系统里根本没有记录这条因果链。
这其实是 AI Agent 大规模落地后必然会撞上的墙。传统软件审计很简单:某个用户账号登录了系统,点了某个按钮,系统执行了某个操作,记录下日志就行。但 Agent 不是这么工作的。用户可能只说了句“帮我把上周的数据整理一下发邮件”,Agent 自己拆解任务、自己决定要调用报表服务、自己组织邮件内容、自己发出请求。中间每一步都不是用户手点的,而是模型根据上下文“推断”出来的。出事的时候,合规要的不是“它干了什么”,而是“谁让它干的、它凭什么这么干、干之前它看到了什么”。这一连串问题,传统操作日志根本回答不了。
还有一个被严重低估的问题:Agent 的行为有概率性。同样的输入,模型版本不同、温度参数不同,甚至同一版本在不同时刻,都可能做出不同的工具调用决策。如果没有把模型版本、提示词快照、完整推理链路记录下来,一旦线上行为发生漂移,你根本分不清是业务代码改了,还是模型悄悄变了。这类问题排查起来痛不欲生,而审计链路恰恰是唯一的定位手段。
所以做 Agent 审计,本质上不是“加个日志模块”,而是把一次 Agent 行为变成一个可回放的证据链:谁发起的、在什么会话上下文里、模型基于哪些信息做了决策、决策调用了哪些工具、传入了什么参数、返回了什么结果、对业务数据产生了什么影响。这个证据链,才是审计和合规真正想要的“可追溯链路”。
顺便提醒一句:不要等出事再做审计。我见过太多团队,Agent 都上线半年了才被合规要求补交审计方案,结果要么日志里只有最终结果没有过程,要么关键字段根本没人埋点,最后只能靠模型产出一堆不可验证的“解释”。到那一步,基本等于重做。审计链路应该和 Agent 主流程一起设计,而不是事后打补丁。
2. 先别急着写日志,审计数据模型才是地基
聊实现之前,我建议先把“记什么”想清楚。很多人一上来就想着接日志系统,结果埋点埋到一半发现字段不够用、链路拼不起来、上下文丢了,又要返工。审计和普通日志最大的区别在于:普通日志是给工程师排障用的,字段少了能忍;审计日志是要拿去回答“谁、何时、基于什么、做了什么、产生了什么影响”这五个问题的,缺一个字段,整条证据链就不成立。
2.1 一个审计事件至少要有哪几类字段
我把审计事件拆成六类,每类都对应合规审查里经常被问到的具体问题:
| 分类 | 字段示例 | 解决什么问题 |
|---|---|---|
| 发起方 | actor_type, actor_id, session_id, user_auth_method | 回答“谁让 Agent 做的” |
| 执行方 | agent_id, agent_version, model_id, model_version, temperature | 回答“是哪个 Agent、哪个模型版本做的” |
| 上下文 | input_excerpt, input_prompt_hash, conversation_history_ref, retrieved_documents_ref | 回答“Agent 当时看到了什么” |
| 决策与行为 | intent_snapshot, tool_calls, tool_parameters, tool_results_digest | 回答“Agent 做了什么、怎么做的” |
| 影响 | resource_access, changed_records, email_recipients, external_api_targets | 回答“产生了什么影响” |
| 合规元数据 | policy_version, human_review_status, risk_level, retention_until | 回答“这个操作按哪版合规策略评估过” |
这里我特别想强调上下文类的字段。很多人只记录工具调用参数,觉得“Agent 调了什么 API、传了什么参数”就够了。但合规真正棘手的是“Agent 为什么这么调”。比如用户说“帮我处理掉异常订单”,Agent 如果直接调用了退款接口,你得能证明它确实是根据用户这句话做的判断,而不是拿着别的会话里的上下文串了。所以输入内容的截断快照、检索到的文档引用、会话历史的引用 ID,都必须跟着审计事件走。出于隐私和成本考虑,上下文可以不全量存,但至少要有摘要和引用 ID,保证审计人员能按图索骥找到原始证据。
2.2 用 Trace 把一次任务变成一棵因果树
单条审计事件看起来像一条 JSON 记录,但审计链路的价值在于“串起来”。用户的一次请求,往往会被 Agent 拆成多个子任务:先查数据库,再调外部服务,然后生成结果回复。每一步都是一个独立的审计事件,它们之间必须有父子关系。
这里我直接复用链路追踪的思想:一次任务是一个 Trace,任务里的每个步骤是一个 Span,每个 Span 记录自己的 audit_event_id,同时通过 trace_id 和 parent_span_id 关联到父级。这样整个任务的因果结构就变成了一棵树。审计人员查问题时,可以先定位到根事件,然后顺着树往下展开,看到它的每一个分支决策。
举个实际例子,下面是一次简单任务的结构化记录:
{ "audit_id": "evt_01H9Z8Z4KZHW0Y7A0P2T", "trace_id": "trace_001e4f1a9c3b", "parent_span_id": null, "span_id": "span_000001", "timestamp": "2025-06-12T15:33:21.482Z", "actor": { "type": "human_user", "id": "user_1024", "auth_method": "sso_ticket" }, "agent": { "agent_id": "data_ops_assistant", "agent_version": "v2.3.1", "model_id": "internal-llm-7b", "model_version": "2025-06-01" }, "input": { "excerpt": "处理掉异常订单", "input_hash": "sha256:af9c..." }, "decision": { "intent": "bulk_refund_orders", "risk_level": "high", "tool_calls": [ { "tool": "refund_api", "params": {"order_ids": ["A1001", "A1002"], "reason": "user_requested_cleanup"} } ] }, "policy_version": "p_v14", "human_review_status": "pending" }注意:每个字段都不是随手拍的。actor 里为什么要带 auth_method?因为“这个人是谁”和“这个人怎么被验证的”是两回事,审计时必须能证明身份来源可信。decision 里为什么要单独放 intent?“工具调用了退款”和“Agent 认为用户要求退款”是两个层面的行为,都要记录,否则事后说不清。
2.3 原文全文、意图哈希与截断快照的平衡
“记录上下文”最容易踩的坑是走极端:要么只存个哈希,后面查证据时什么都看不到;要么整段原文存进去,隐私和成本双双爆表。我的做法是三层搭配:
第一层,意图摘要。模型在决策阶段通常会产出结构化的意图快照(比如“bulk_refund_orders”),这个必须原样存,这是最核心的证据。第二层,输入原文的脱敏截断。只保留前一定长度(比如 2000 字符),并且过一遍脱敏规则,把手机号、身份证、密钥这类敏感信息替换掉。第三层,完整原文的引用哈希。完整内容存到受控的原始数据存储中,审计链路里只放哈希和引用 ID,需要核验时再走专门流程解封。
这样设计的考量很实际:审计链路本身会被同步到多套系统,存太多敏感原文等于扩大了暴露面。而哈希可以保证“这条记录确实对应于这一次原始输入”,防止事后被篡改或抵赖。对大部分合规场景,有摘要加引用 ID 已经足够还原现场。
3. 可追溯链路的工程落地:埋点、管道与存储
数据模型设计完后,下一个问题是在哪里记、怎么传、怎么存才能保证可靠。这一节我只聊工程实现里真正影响审计质量的几个决定:埋点位置、链路协议和存储选型。别小看这三件事,做错了整个链路就会变成“看起来有、实际上查不了”的花架子。
3.1 埋点放在 Agent SDK 调度层,而不是散落在业务代码里
我见过最不推荐的埋点方式,是在每个工具函数里手动写日志。看着灵活,实际上因为 Agent 的工具调用是动态路由的,工具函数可能被各种场景复用,手动埋点不仅容易漏,还容易把业务日志和审计日志混在一起。审计事件的粒度、字段、时序全靠开发者自觉,基本等于没有。
正确的做法是把埋点下沉到 Agent 执行框架的调度层。通常一个 Agent 框架会有这么几个必经环节:接收用户输入、模型推理生成决策、执行工具调用、生成最终输出。在调度层统一拦截这四个环节,每个环节自动生成一条审计事件,业务代码完全无感。比如我在封装工具执行时,会在调用前后分别记录两条事件:
class AuditedToolExecutor: def execute(self, tool_name: str, params: dict, context: AuditContext): # 调用前:记录“准备调用什么” audit.send({ "event_type": "tool_invocation_started", "trace_id": context.trace_id, "span_id": generate_span_id(), "tool_name": tool_name, "params_digest": hash_json(params), "policy_check": check_policy(tool_name, params) }) result = self._invoke_tool(tool_name, params) # 调用后:记录“实际结果是什么” audit.send({ "event_type": "tool_invocation_completed", "trace_id": context.trace_id, "span_id": previous_span_id, "result_summary": summarize_result(result), "result_hash": hash_json(result) }) return result关键点是“调用前”和“调用后”各记一条。只记调用后的话,如果工具执行中途崩溃或者超时,你根本不知道 Agent 到底有没有发起过这个调用。两条事件合在一起,才能完整覆盖“尝试调用”和“调用成功/失败”两个事实。
还要提一点:埋点代码必须和 Agent 核心流程同生命周期。有些团队把审计上报做成了异步任务,请求处理结束了再慢慢补发,结果进程一退出或者队列一堆积,审计事件就丢了。我现在的做法是:先同步写入本地可靠缓冲(WAL),再异步批量转发到审计管道。同步写保证不丢,异步转保证不阻塞主流程。
3.2 复用 OpenTelemetry 的 Trace 语义,而不是另造一套链路
很多团队一听到“可追溯链路”,第一反应是自己设计一套 trace 系统。我的建议是能复用 OpenTelemetry 的语义就复用,理由很实在:大家都要记录 trace_id、span_id 和父子关系,完全没必要重复造轮子。OpenTelemetry 的 Span 天然支持分层嵌套,并且有现成的跨语言 SDK,Agent 平台里无论组件用的是 Python、Java 还是 Go,都能往同一个链路模型里挂数据。
有人会问:OpenTelemetry 不是主要干性能监控的吗,拿来做审计会不会不搭?说实话会有一些不搭,所以要自己在这个底座上做扩展。我会把审计专用的字段塞进 span 的 attributes 里,比如 actor 信息、input_hash、policy_version,同时自定义一个独立的 audit event exporter,把链路和详情分别送往不同目的地:链路拓扑进可观测系统,审计详情进合规专用存储。这样两边的诉求都满足,又不用重写链路逻辑。
审计场景里我还给 OTel span 增加了一个扩展属性:audit.retention_days。为什么加这个?因为审计数据的保留周期和普通 trace 不一样。普通 trace 一周后就无所谓了,审计事件可能要留 180 天甚至更久。给每个 span 打上保留周期标签,后续冷热归档和到期清理可以直接按这个字段执行,不用再逐条判断。
3.3 存储选型:审计日志不能只丢进 Elasticsearch
讨论存储选型前,建议先想清楚一个事情:审计链路的数据是只增不改、强顺序、高价值,而且必须防丢失。这意味着它不是一般意义上的“日志”,不能随随便便进一个共享日志集群就完事。我试过把审计事件和普通业务日志混在同一个 Elasticsearch 索引里,后果是:因为禁止修改和删除,审计事件必须进独立索引;因为查询模式不同,要支持按 trace_id 遍历整棵树,而不是简单关键字检索。混在一起后,索引策略、生命周期管理全都冲突。
我现在采用的方案是分层存储。审计事件先进入消息队列(Kafka 或同类产品),然后分两条路径:一条写入对象存储做不可变归档,按 trace_id 做分区文件;另一条写入检索型存储(比如 ClickHouse 或专门的审计索引),支撑日常按用户、按时间、按工具检索。对象存储解决“如何永久保住原始证据”的问题,检索存储解决“怎么快速找到某个事件”的问题。
选型对比我整理了一个表:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| Elasticsearch 单集群 | 上手快、全文检索方便 | 数据不便于长期冻结、权限控制粒度一般 | 小团队、审计量不大 |
| 对象存储 + 检索倒排 | 不可变、成本低、容量几乎无限 | 需要自己维护两级索引一致性 | 生产级审计链路,强烈推荐 |
| 传统关系型数据库 | 事务强、字段约束严格 | 写入扩展性差、海量事件性能受限 | 审计量小且有强事务需求 |
| 专用审计即服务产品 | 省心、自带合规模板 | 数据出境/自托管约束、价格高 | 业务量小、不想自建 |
我特别想说一下消息队列的选择。审计事件不能直接同步写对象存储,因为 Agent 是高频调用的,同步写会把性能拖垮。让事件先落队列,再由消费者批量写归档,是最稳妥的架构。队列本身必须开启持久化,并且确认审计消费者成功写入后才 ack,宁可消费慢一点,绝不丢事件。
3.4 链路完整性校验:间歇性丢事件是个隐形雷
审计链路最让人头疼的问题不是设计不好,而是事件在传输过程中偶尔丢失。单看一天的丢失率可能不到 0.1%,但合规审计要求的是“零丢失”证据链。你没法保证每条日志都检查一遍有没有缺失,所以必须设计自动校验机制。
我的做法是两套校验并行。第一套是序列号校验:每个 actor 会话内,审计事件按自然序号递增,存储端做周期性扫描,发现序号断档就告警并触发补采。第二套是心跳校验:Agent 在长时间任务中,如果超过 N 分钟没有产生任何审计事件,调度层要主动发一条“心跳事件”,表明 Agent 仍在运行但无决策发生,防止“静默丢事件”和“真没做事”被混淆。有了这两套机制,审计链路的完整性才是可证明的,而不是靠运气。
4. 身份与不可篡改:审计链路最容易翻车的两处
审计链路有两个环节如果不认真设计,整条链路的可信度直接归零。一个是“发起人身份”怎么确定,一个是“事件内容”怎么防篡改。前者决定你能不能回答“谁让 Agent 做的”,后者决定你记录的证据能不能被采信。这俩翻车了,前面做再多工作都白搭。
4.1 actor 认证粒度:别只认一个“登录用户”
Agent 场景里,一次任务的发起方不一定只有人类。它可能是用户在前端触发的,可能是另一个 Agent 在编排流程时触发的,还可能是定时调度系统批量触发的。如果审计日志里只记录“system”或者一个笼统的账号,那合规问起来的时候,你仍然说不清是谁。
我在审计链路里把 actor_type 明确分为三类:human_user、service_account、agent_identity。每一类用不同的字段组合记录身份。human_user 要记录登录凭证类型和会话 ID;service_account 要记录业务系统名、请求方 IP 和鉴权凭证指纹;agent_identity 要记录父 Agent 的 audit_id,因为 Agent 不是凭空产生的,它一定来自另一个可追溯的发起链路。
这里容易忽略一个细节:用户在前端操作时,后端服务可能已经换了身份。比如用户通过网关发起请求,网关再以服务账号身份调用 Agent API。如果你只记录网关的服务账号,就丢了真正的发起人。所以我要求 Agent 平台在接收请求时,强制要求上游透传原始的 actor headers(包含原用户 ID 和鉴权上下文),服务账号身份只作为调用链路里的中间层记录。宁可多记录一条“以什么身份代理调用”,也不能把发起人用一个笼统的 system 糊弄过去。
4.2 防篡改:哈希链与定期签名快照
审计事件的存储端虽然是不可变对象存储,但合规还关心另一个问题:存储上层的检索索引,或者运维同学有权限的数据库,有没有可能被悄悄改掉?为了对抗这种风险,我做了两层保护。
第一层是哈希链:每条审计事件在写入归档时,带上一个 hash 字段,其值是上一条审计事件的摘要加本条事件内容后计算出来的摘要。这样任意一条事件被修改,都会导致后续所有事件的哈希校验不通过。通俗点说,每条事件都像一页账本,页码和上一页的结算数锈死在一起,撕掉任何一页都会露出破绽。第二层是定期签名快照:每隔一段时间,把截至当前的全量检索生成一份签名摘要,用内部公钥基础设施的私钥签名后,单独存到分离开的通道里。日常审查不需要比对所有事件,只需要定期抽查签名快照是否一致,就能低成本发现篡改迹象。
注意,这些机制不是为了防黑客,而是为了防“内部修改后被抵赖”。做审计最尴尬的事情是:审计记录被管理员改掉了,然后你说不是自己干的。哈希链和签名快照能让这种情况在技术上就不可能。哪怕运维权限再大,也改不了已经被签名固化过的历史批次。
4.3 脱敏与隐私边界:日志里不能裸奔敏感数据
审计日志记录得太详细,本身就是一把双刃剑。你要记录“Agent 看到了什么”才能还原现场,但用户对话里可能带着手机号、地址、身份证、工单里的机密内容。如果这些内容以明文形式穿过整个审计管道,审计系统本身就会变成一个新的数据泄露面。
我的脱敏做法分两级。第一级,在源头做模板识别加 NER 识别,把符合手机号、邮箱、身份证格式的字段,以及人名、组织名这类实体,先替换为掩码,再进行哈希,最后才进入审计事件。第二级,在检索存储里再挂一层动态脱敏权限:只有带特定角色的人才能查看原文摘要,普通审计员看的是掩码版本,需要看原始字段时必须走解密申请流程,并且解密操作本身也要记审计日志。
这里特别提醒:脱敏规则一定要在审计链路的最开始执行,不要在存储端做。因为存储端脱敏意味着原始数据已经完整落盘了,风险早已存在。在源头脱敏,虽然增加了一点计算成本,但可行。
5. 从“记录”到“能用”:让合规检查真正落地
审计链路建好之后,最容易出现的新问题是“数据有了,但没人用”。记录是手段,能支持合规检查才是目的。合规同学要的不是一张全量导出表,而是“这个高危操作有没有经过审批、那个用户有没有超权限访问、上个月的所有退款请求有没有遗漏的人工复核”。这要求审计系统提供的不只是查询接口,而是一套可操作的合规检查能力。
5.1 事前、事中、事后三层策略
我的做法是把它分成三层:事前策略、事中拦截、事后扫描。事前策略指 Agent 启动任务之前,审计框架会先检查这个 actor 有没有权限执行这个意图,没有权限直接拒绝,并且拒绝本身也要记录为一条审计事件。事中拦截指工具调用正要发生时,实时检查目标接口的权限范围和频率限制,发现超限调用立即中止并触发告警。事后扫描指每天或每周跑一次全量链路分析,找出那些形式上符合权限、但行为上可疑的模式,比如某个用户短时间内多次让 Agent 读取同一批客户数据,或者 Agent 自主调用了评分策略之外的接口。
我自己是强烈建议三层都有的,因为单靠某一层都有漏洞。事前策略挡不住“有权限但滥用”的行为,事中拦截挡不住分布式慢速绕过,事后扫描又太迟,无法阻止即时风险。三层配合,才能覆盖“该拦的拦掉、该审的审出来”。
5.2 定期生成可读的审计报告,而不是丢一堆 JSON 出去
给合规部门看的东西,绝对不能是 JSON 原文。我踩过这个坑:一开始我把审计查询接口给合规同学用,他们看了一眼就关掉了,反馈是“看不懂”。后来我改成自动化生成周报形式的审计报告,效果一下子好了很多。报告里包含几个固定板块:高危操作清单、未完成人工复核事件列表、按 actor 维度的操作汇总、异常行为模式与建议处理方案、以及保留策略执行状态。
生成报告的功夫其实不值钱,真正值钱的是每一块背后的聚合逻辑。比如“高危操作”的定义必须和业务一起确定:哪些工具调用算高危?是退款接口、是删除数据接口、还是外发邮件接口?这个清单不是技术团队拍脑袋定的,而是和业务负责人过一遍,每季度重估一次。有了这个清单,事后扫描和报告生成才有统一的标尺。
5.3 不要依赖模型输出的“解释”
还有一点我必须专门提:Agent 的推理过程,不能只依赖模型在回复里写的解释。LLM 会解释,但解释可能幻觉化、简略化、甚至美化自己的行为。举例来说,模型可能在回复里写“我已经整理了所有需要的订单”,但实际上它只调用了过滤条件不当的查询。如果审计只存模型输出文本,那你拿到的是一条美化过的故事,而不是真实行为。
要让“解释”可以用,必须把它结构化为决策快照。模型的原始输出可以作为参考信息挂在审计事件里,但真正用于合规判断的是结构化 intent、实际调用的工具列表和工具参数。如果模型声称做了某件事但代码审计链路里没有对应工具调用记录,那这条声称就不可信。这套“声明必须与行为记录匹配”的原则,是 Agent 审计里最核心的判定逻辑。
6. 上线半年踩过的坑,和现在沉淀下来的做法
最后的章节,我想分享一些真实上线过程中踩过的坑。很多问题是我在测试环境完全不会遇到的,只有把 Agent 放进真实流量里跑起来,才会暴露出来。希望这些内容能帮你绕过一些弯路。
6.1 坑一:异步上报时序错乱,链路树都拼不起来
最初我图省事,把审计上报做成了纯异步:业务线程处理完之后就把事件丢进一个内存队列,由后台线程批量上报。结果高峰期一跑,同一 trace 下的子任务事件比父任务事件还先到存储端,链路树直接乱了。定位问题很简单,但修复很麻烦——得重新梳理上报顺序和缓冲策略。后来我换了方案:单个 trace 内的所有事件,在写入审计管道前先经过一个局部分区排序器,再批量上报。保证同一个 trace 内事件顺序稳定,跨 trace 就不那么关心顺序了。这个容忍度要分清:链路内的顺序是审计正确性的关键,全局顺序不是。
6.2 坑二:只记工具调用的参数,没记模型的原始决策输出
有一回出了个事故:Agent 回复客户说“已为你自动续费”,但实际工具参数传错了价格档位。系统日志里查得到工具调用参数,也查得到最终回复文本,唯独找不到模型在决策时拿到的上下文快照和它当时的推理摘要。结果怎么都说不清是哪一步出的错。从那以后,我把“模型决策时看到的输入摘要”和“模型选定的意图快照”强制加进了审计事件表。这个字段带来的存储成本不小,但相比一次说不清的事故,这点成本太值了。
6.3 坑三:没规划保留周期,半年后归档数据一塌糊涂
一开始我把所有审计数据都留满 365 天,结果发现成本极速膨胀,而且不少历史数据根本不会被翻到。后来我和业务方重新梳理了分级保留策略:普通操作日志保留 180 天,高危操作日志和涉及争议的会话保留 3 年,且高危操作做年度归档。这件事要提前设计字段,不能事后补,否则你会被大量历史事件拖到归档流程里动弹不得。
6.4 坑四:敏感字段差点直接写进审计日志
我至今记得有一次做安全复查,发现审计日志里完整记录了用户对话中的一串银行卡号。当时虽然没有造成泄露,但合规直接给我亮了黄牌。后来我意识到,脱敏不能靠写代码时“记得”去做,而要靠审计事件组装时的强约束:所有输入必须经过同一个上下文净化器再入日志,净化器本身批量识别敏感数据格式。你可以在测试阶段用造数工具批量灌入模拟敏感数据,验证净化率,这样能提前发现规则漏洞。
6.5 现在沉淀下来的核心做法
经过这大半年的反复调整,我现在做 Agent 平台的第一原则变成了:在画系统架构图的同时,就把审计链路画出来。具体沉淀下来,大概四句话——所有决策和工具调用必须有结构化审计事件;所有事件必须能串成 trace 树;所有事件必须以不可篡改的方式落盘;所有审计能力都要同时支持在线查询和定期报告。这套方法可能不适用所有团队,但如果你正在为“谁让 Agent 做了什么”发愁,照着这个框架走,至少能保证回答得出合规的一连串追问。
回到开头那个问题。当年合规问我“这个动作到底是谁让 Agent 做的”,现在我能直接拉出完整链路:用户在某时某刻、通过什么认证方式、在哪个会话里、输入了什么内容、Agent 的哪个版本基于该输入生成了什么意图、调用了哪些工具、传了什么参数、拿到了什么结果。这个链路比任何解释都硬核,也让 Agent 系统从“越用越黑盒”变成了“每一步都有据可查”。我相信这也是所有想让 Agent 真正走进生产环境的人,迟早要补上的一课。