ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

LLM应用溯源审计:从黑盒输出到全链路可解释的架构实践

LLM应用溯源审计:从黑盒输出到全链路可解释的架构实践

1. 黑盒输出为什么必须配一层审计

大模型应用上线之后,最让人睡不踏实的一件事,不是模型答得不够聪明,而是它答完之后你没法解释。用户问了一个问题,模型给了一段回答,这段回答里引用了哪些数据、经过了哪些中间步骤、有没有被检索到的脏数据带偏、有没有在某个环节被注入的指令劫持——这些在传统的日志体系里几乎是空白。你看到的只有一条 request 和一条 response,中间全是黑的。

我在做第一个 LLM 应用的时候,就吃过这个亏。当时是一个内部知识问答系统,接的是公司自己的文档库。上线第二周,有同事反馈说某个问题的答案里出现了一段明显不属于任何文档的内容,像是模型自己编的。我去查日志,发现只能看到用户问了什么、模型答了什么,检索到了哪些 chunk、这些 chunk 来自哪个文档、rerank 之后的排序是什么、最终拼进 prompt 的上下文长什么样——全都没有记录。那次排查花了整整两天,最后靠复现才勉强定位到是一个过期文档没被清理,被检索出来当成了依据。

这件事之后我就认定了一个原则:LLM 应用的日志不能只记输入输出,必须把整条推理链路的关键节点都留痕。这就是我理解的 AI-Native 审计层——它不是传统意义上那种事后合规检查的审计,而是从系统设计之初就内建进去的、贯穿请求全生命周期的可溯源能力。

为什么叫 AI-Native?因为传统的审计思路是"先跑起来,再补日志",而 AI 应用的特性决定了这种补法根本补不全。LLM 的推理过程涉及检索、重排、上下文组装、工具调用、多轮对话状态管理,这些环节的数据结构差异极大,如果不在设计阶段就统一埋点规范,后期想拼出一条完整的溯源链几乎不可能。所以审计层必须是原生的,是架构的一部分,而不是外挂的一个模块。

这一篇主要讲溯源审计的设计思路和落地细节,包括数据血缘怎么建、审计事件怎么定义、存储怎么选、查询怎么做。下一篇会讲对抗性场景下的审计——也就是当有人故意想绕过你的审计、注入恶意指令、或者用模型做不该做的事时,审计层怎么变成一道防线。

2. 溯源审计到底要溯什么

2.1 从"结果审计"到"过程审计"的转变

大部分人第一次做 LLM 审计,做的都是结果审计:记录用户输入、记录模型输出、打个时间戳、存起来。这够不够?对于简单的问答场景,勉强够用。但只要你涉及 RAG、Agent、多轮对话中的任意一个,结果审计就完全不够了。

我举个具体的例子。用户问"我们公司去年的营收是多少",模型答"根据公开数据,约为 3.2 亿元"。结果审计只能看到这一问一答。但如果这个数字是错的,你怎么查?你需要知道:检索阶段召回了哪些文档片段?这些片段里有没有包含营收数据?rerank 之后哪个片段排在第一?拼进 prompt 的上下文里,营收数据出现在第几段?模型是基于哪一段生成的这个数字?如果检索阶段根本没召回正确的文档,那是检索的问题;如果召回了但 rerank 把它排到了后面被截断了,那是 rerank 或上下文窗口的问题;如果都正确但模型读错了,那是模型的问题。没有过程审计,你永远只能猜。

所以溯源审计的核心转变是:从记录"发生了什么"到记录"为什么发生"。每一个关键决策节点都要留下证据,让事后可以重建整条推理链路。

2.2 数据血缘:审计的骨架

数据血缘(Data Lineage)这个词在大数据领域不新鲜,但在 LLM 应用里,它的含义被扩展了。传统数据血缘追踪的是表与表、字段与字段之间的流转关系。LLM 应用里的血缘要复杂得多,因为它涉及非结构化数据、向量检索、概率性排序、以及模型内部的不可解释变换。

我设计的血缘模型包含三个层次:

第一层是数据源血缘。每个文档、每个知识条目在进入系统时就要打上唯一标识,记录它的来源、版本、入库时间、最后更新时间、负责部门。这一层解决的是"这个知识从哪来"的问题。

第二层是检索血缘。每次检索请求都要记录:query 是什么、用了哪个 embedding 模型、检索到了哪些 chunk(带 score)、rerank 之后的新排序是什么、最终选中了哪些 chunk 进入上下文。这一层解决的是"模型看到了什么"的问题。

第三层是生成血缘。记录最终拼装的 prompt 全文、模型版本、温度参数、token 消耗、生成耗时、以及输出与输入 chunk 的对应关系(如果做了 attribution 的话)。这一层解决的是"模型基于什么生成了什么"的问题。

这三层血缘通过一个统一的 trace_id 串联起来。一个请求进来,生成一个 trace_id,后续所有环节的审计事件都挂在这个 id 下面。查询的时候,拿 trace_id 一拉,整条链路就出来了。

2.3 审计事件的粒度设计

粒度太粗,查不到细节;粒度太细,存储爆炸。我踩过的坑是:一开始每个 token 都记,结果一天下来光审计日志就几十个 G,查询还慢得要死。后来调整成关键节点记录 + 采样记录的策略。

关键节点包括:请求进入、检索完成、rerank 完成、上下文组装完成、模型调用开始、模型调用结束、输出后处理完成、响应返回。这些是必记的,每个节点记录结构化的事件数据。

采样记录包括:token 级别的生成概率、attention 权重(如果能拿到的话)、中间层的 embedding 向量。这些数据量太大,只对采样率内的请求记录,采样率根据业务重要性和存储成本动态调整。

提示:审计事件的 schema 一定要在设计阶段就定好,并且做成可扩展的。我一开始用固定的 JSON 结构,后来想加字段就得改表结构,非常痛苦。后来改成 schema registry 的方式,每个事件类型注册自己的 schema,新增字段不影响老数据。

3. 审计层的技术选型与架构落地

3.1 存储选型:为什么我最终选了混合方案

审计数据的查询模式很特殊:写入量极大、单条数据不大、查询以 trace_id 为主键、偶尔需要按时间范围或用户维度做聚合分析。这种模式用单一存储很难兼顾。

我试过几种方案:

方案写入性能查询性能成本适用场景
纯关系型数据库差好高小规模、强事务
纯文档数据库好中中中等规模、灵活 schema
纯列式存储极好差(点查)低大规模、分析型
混合方案好好中生产环境推荐

最终我采用的是混合方案:热数据用文档数据库,冷数据归档到对象存储,分析型查询走列式存储。

具体来说,最近 7 天的审计事件存在文档数据库里,支持按 trace_id 快速点查和按时间范围的范围查询。超过 7 天的数据,每天凌晨归档到对象存储,格式用 Parquet,压缩比高,成本低。需要做聚合分析的时候,用列式查询引擎直接查对象存储上的 Parquet 文件。

这个方案的好处是:日常排查问题查热数据,快;合规审计查历史数据,全;做统计分析查列式存储,灵活。成本上,对象存储比数据库便宜一个数量级,长期留存压力小很多。

3.2 埋点方式:SDK 还是代理

埋点有两种做法:一种是在应用代码里显式调用审计 SDK,另一种是在网关层做代理自动采集。

我两种都用过,最后的选择是以 SDK 为主、代理为辅。

SDK 的好处是能拿到应用层的语义信息。比如检索环节,SDK 能直接拿到召回列表、score、rerank 结果这些结构化数据。代理层只能看到 HTTP 请求和响应,拿不到这些中间状态。

代理的好处是无侵入,对于已经上线的系统,加代理比改代码快。而且代理能统一处理所有经过网关的请求,不会漏。

我的做法是:核心链路用 SDK 埋点,保证语义完整;网关层加代理做兜底,保证不漏请求。两边用同一个 trace_id 关联,查询的时候合并展示。

# 审计 SDK 的核心接口设计 class AuditTracer: def __init__(self, trace_id: str, user_id: str, session_id: str): self.trace_id = trace_id self.events = [] def log_retrieval(self, query: str, chunks: list, model: str): """记录检索事件""" self.events.append({ "type": "retrieval", "timestamp": time.time(), "query": query, "chunks": [ {"id": c.id, "score": c.score, "source": c.source} for c in chunks ], "embedding_model": model }) def log_generation(self, prompt: str, output: str, model: str, temperature: float, tokens: dict): """记录生成事件""" self.events.append({ "type": "generation", "timestamp": time.time(), "prompt": prompt, "output": output, "model": model, "temperature": temperature, "token_usage": tokens }) def flush(self): """批量写入审计存储""" audit_store.batch_write(self.trace_id, self.events)

这个接口设计的关键点是:每个事件都带类型和时间戳,事件之间通过 trace_id 关联,事件内部的数据结构根据类型不同而不同。这样既保证了灵活性,又保证了可查询性。

3.3 性能开销:怎么把审计对主链路的影响降到最低

审计最大的风险是拖慢主链路。我实测过,如果同步写审计日志到数据库,P99 延迟会增加 200ms 以上,这在生产环境是不可接受的。

我的优化策略是异步 + 批量 + 本地缓冲:

异步是指审计写入不阻塞主流程。主流程调用log_xxx方法时,只是把事件放到内存队列里,立即返回。后台有专门的 worker 线程从队列里取数据,批量写入存储。

批量是指不是每条事件都单独写一次,而是攒够一定数量或者超过一定时间间隔再写。我设置的阈值是 100 条或 1 秒,哪个先到就触发写入。

本地缓冲是指如果审计存储暂时不可用,事件先写到本地磁盘的缓冲文件里,等存储恢复了再补写。这样即使审计系统挂了,也不会丢数据,更不会影响主业务。

注意:异步写入有一个坑——如果进程崩溃,内存队列里没来得及 flush 的事件就丢了。我的解决办法是在关键节点(比如模型调用返回后)强制 flush 一次,保证核心链路的事件不丢。非关键节点的事件可以容忍少量丢失。

实测下来,这套方案对主链路的延迟影响在 5ms 以内,基本可以忽略。

4. 溯源查询:怎么从海量审计数据里快速定位问题

4.1 查询场景拆解

审计数据写进去只是第一步,能不能查出来才是关键。我总结了几个高频查询场景:

场景一:按 trace_id 查完整链路。这是最常用的,用户反馈某个回答有问题,拿 trace_id 一拉,整条链路的所有事件按时间排序展示。这个查询走文档数据库的主键索引,毫秒级返回。

场景二:按用户查历史请求。某个用户投诉说最近几次回答都不对,需要看他最近的所有请求。这个查询走 user_id 索引 + 时间范围,需要建复合索引。

场景三:按内容关键词查。想查所有引用了某个文档的回答,或者所有输出里包含某个敏感词的回答。这个查询需要全文索引或者倒排索引,文档数据库原生支持。

场景四:聚合分析。比如统计某个时间段内检索命中率、平均 token 消耗、模型调用失败率。这个走列式存储,用 SQL 做聚合。

4.2 链路重建的展示设计

查到数据之后,怎么展示也很重要。我一开始就是把 JSON 原样吐出来,结果没人看得懂。后来做了一个链路可视化的页面,把事件按时间轴排列,每个事件用卡片展示关键信息,点击可以展开详情。

链路展示的核心是让因果关系一目了然。比如检索事件和生成事件之间,用箭头标出哪些 chunk 进入了生成阶段的上下文。这样排查的时候,一眼就能看出是检索没召回,还是召回了但没被用上。

我还加了一个对比视图:把正常请求和异常请求的链路并排展示,差异高亮。这个在排查"为什么这个请求出错了"的时候特别有用,因为很多时候问题不是某个环节完全错了,而是某个参数偏了。

4.3 查询性能优化

审计数据的特点是写多读少,但读的时候往往要求快。我的优化经验:

索引设计:trace_id 是主键,必须有。user_id + timestamp 建复合索引,支持按用户查历史。event_type + timestamp 建索引,支持按类型做统计。全文索引建在 query 和 output 字段上,支持内容搜索。

冷热分离:热数据(7 天内)在文档数据库,查询走内存索引,快。冷数据在对象存储,查询走列式引擎,稍慢但可接受。查询接口根据时间范围自动路由到对应的存储。

预聚合:对于统计类的查询,不要每次现算。我每天凌晨跑一个预聚合任务,把前一天的统计数据算好存起来。查询的时候直接读预聚合结果,秒级返回。

缓存:对于频繁查询的 trace_id,结果缓存在 Redis 里,设置合理的过期时间。实测缓存命中率在 60% 以上,显著降低了存储压力。

5. 踩过的坑与实战经验

5.1 审计日志本身的安全问题

审计日志里包含了大量的敏感信息:用户的原始问题、模型的完整输出、检索到的文档内容、甚至 prompt 里的系统指令。这些数据如果泄露,后果比业务数据泄露还严重。

我踩过的坑是:一开始审计日志和业务日志存在同一个数据库里,权限没有分开。后来做安全审计的时候发现,任何能访问业务数据库的人都能看到审计日志,这等于把系统的底裤暴露了。

解决办法是审计存储独立部署、独立鉴权、加密存储。审计数据库和业务数据库物理隔离,访问审计数据需要单独的权限,敏感字段(如用户输入、模型输出)在存储时加密,查询时解密。密钥管理走独立的 KMS,不和业务密钥混用。

另外,审计日志本身也要有审计——谁在什么时候查了哪些审计数据,这个也要记录。防止内部人员滥用审计权限。

5.2 采样率设太高导致存储爆炸

前面提到过,我一开始每个 token 都记,结果存储成本失控。后来改成采样,但又踩了另一个坑:采样率设成固定值,导致重要请求没被采样到,出问题的时候查不到。

正确的做法是动态采样:根据请求的重要性和异常程度调整采样率。比如:

  • 普通请求:1% 采样
  • 包含敏感操作的请求:100% 记录
  • 模型输出被后处理修改过的请求:100% 记录
  • 用户反馈有问题的请求:100% 记录(通过反馈接口触发补录)

这样既控制了成本,又保证了关键请求的可追溯性。

5.3 trace_id 传递断裂

在微服务架构下,trace_id 需要跨服务传递。我遇到的问题是:LLM 应用往往涉及多个服务——网关、检索服务、模型服务、后处理服务——如果 trace_id 在某个环节丢了,链路就断了。

解决办法是统一 trace_id 规范 + 强制透传。trace_id 在网关生成,通过 HTTP header 或者消息队列的 metadata 传递,每个服务都必须把 trace_id 透传给下游。我在代码规范里明确要求:任何跨服务调用都必须带 trace_id,code review 的时候重点检查。

还有一个细节:异步任务(比如后台的文档索引任务)也要有 trace_id,但这个 trace_id 和用户请求的 trace_id 是两套体系。我的做法是用 parent_trace_id 关联,用户请求触发的异步任务,parent_trace_id 指向用户请求的 trace_id。

5.4 审计数据的保留策略

审计数据要存多久?这个问题没有标准答案,取决于合规要求和业务需求。我的经验是分级保留:

  • 原始事件数据:保留 90 天,之后归档到冷存储
  • 聚合统计数据:保留 2 年,用于趋势分析
  • 关键审计事件(如涉及敏感操作的):保留 3 年以上,满足合规要求
  • 加密的敏感字段:单独管理,保留策略更严格

保留策略要写成配置,不要硬编码。不同业务线可能有不同的合规要求,做成可配置的才能灵活应对。

6. 从审计到对抗:下一步要解决的问题

溯源审计解决了"事后能查"的问题,但光能查还不够。当有人故意想绕过审计、注入恶意指令、或者用模型做不该做的事时,审计层需要变成一道主动防线。

下一篇会讲对抗性场景下的审计设计,包括:怎么检测 prompt 注入、怎么识别异常的检索模式、怎么在审计层做实时拦截、以及怎么用审计数据做模型行为的基线建模。这些内容涉及到更多的实时计算和规则引擎,复杂度比这一篇的溯源审计要高不少。

我在实际落地中的体会是:审计层的价值不在于技术多先进,而在于能不能坚持把每一个关键节点都留痕。很多团队做审计做不下去,不是因为技术难,而是因为觉得"记这么多没用"。等到真出问题的时候,才发现当初少记的那一个字段,就是定位问题的关键。所以我的建议是:宁可多记,不要少记;宁可存储成本高一点,不要等到查不到的时候后悔。

返回列表