ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI工程可问责性:从审计日志到模型版本追溯的实践

AI工程可问责性:从审计日志到模型版本追溯的实践 Accountability 在 AI 工程中的中文表达是“可问责性”它解决的问题很具体当模型输出异常、业务受损、用户投诉时系统能不能在几分钟内回答“谁发起的请求、用的哪个模型版本、输入是什么、为什么这样返回、由谁负责复核”。如果这些问题答不上来生成式对话功能跑得再流畅也只是一个实验不是生产系统。Accountability and AI 这个组合常出现在治理类讨论中但把它当作工程需求来设计会更有价值。可问责性不是一个事后追责的流程它需要在模型调用发生前就设计好上下文、接口、日志和权限模型。换句话说它是一项和性能、安全平行的非功能要求。下面从工程视角拆解这个主题。先定义可问责性在代码和配置中的含义再设计一个带审计日志的 AI 问答服务用 Spring Boot 实现最小闭环然后扩展到 AI Agent 场景和生产环境清单。全程目标只有一个真出问题时团队能快速完成“发现、追溯、定位、处置、恢复”。1. 先理解 Accountability 在 AI 工程里的具体含义1.1 可问责性不是抽象口号而是能回答五个问题一句话解释可问责性就是系统一旦出现错误行为团队能够找到发生链路、定位影响范围、确定责任对象并具备撤销错误行为的能力。传统软件出错时可以通过堆栈、报错 time、版本 tag 定位问题。但 AI 应用的错误类型多了一层不确定性。模型可能不是“崩溃”而是“一本正经地给出了错误答案”不是连接失败而是知识库检索到了不相关片段。此时只靠异常日志无法解释问题必须额外保存调用当时的运行上下文。我把 AI 可问责性拆成五个必须回答的问题问题需要留存的证据谁发起了这次调用用户 ID、会话 ID、来源系统、权限角色用了哪个模型模型名称、版本号、prompt 版本、部署环境输入了什么数据用户问题、上下文、检索到的文档标识、系统提示词模型为什么返回这个结果温度参数、候选数量、中间步骤、知识库引用片段结果如何被使用和复核是否人工审核、审核人、最终状态、如何回滚这五个问题落到工程上就是一组字段、几段日志和一张查询分析表。不要把模型提供方在线上的“调试面板”当作追溯手段那些信息通常不完整也往往留存时间有限。真正可靠的方式是在自己的系统边界内主动记录。1.2 Accountability 不等于可观测性但依赖可观测性可观测性关注系统当前是否健康比如延迟、错误率、CPU 和内存。Accountability 关注的是“谁对哪个决策负责”比如一次自动生成的结果由哪个模型版本产生、系统提示词是否有改动。举个例子。传统组件返回 500默认是代码缺陷或环境异常。但 AI 模型返回同一个句子原因可能完全不同模型服务端升级了权重、检索系统召回范围改变、用户输入里注入了新指令、或者运营同学在后台修改了提示词。只看 Prometheus 指标很难定位这类问题。所以 AI 的可问责性需要四个能力叠加可追溯每个请求从进入系统开始就携带全局 TraceId并穿透模型调用、检索、工具调用等环节。可解释除输入输出外保留当时的系统提示词、知识库片段、工程参数、运行步骤。可控制对高风险场景设置规则拦截、人工复核和主动终止能力。可审计把调用记录、变更记录和审批记录保存在可查询的存储中而不是只输出到临时控制台。注意AI 可问责性不是“加一个日志表”就完成的事。它要求每次业务用户看到的生成结果都能追溯到一次真实的模型调用以及该次调用的完整配置快照。1.3 最小闭环也要包含记录、复核、回滚三个环节在一个小型 AI 应用里不需要一开始建完整的治理平台。但至少要完成闭环调用入口记录“谁在什么场景下发起请求”模型调用后记录“哪个模型、什么版本、多少 token、耗了多少成本”输出经过风险规则后高风险内容能进入待复核状态当模型版本异常时能通过配置快速切换旧版本。这三个环节对应到研发动作分别是写拦截器、写审计仓库、写配置外置。下面的案例会逐步把它们串起来。2. 从企业知识库问答场景拆出问责闭环2.1 场景需求先定清楚假设要做一个企业知识库客服问答接口。业务希望 AI 只基于内部文档回答不要编造对于超出知识库范围或可能误导客户的问题接口必须保守回答。同时每次生成结果都要留痕方便内容运营同事抽查也方便出现质量问题后快速回滚。这个需求在功能上很容易做但工程上要拆成几个子需求用户身份必须由网关或请求头传递不能完全信任前端传来的用户名字段。模型名称、版本、温度等参数要外置修改配置后必须可追溯。模型调用的输入、输出、异常、耗时、token 消耗要一起入库。输出需要经过风险规则引擎一旦命中标记为待人工复核。若模型版本有问题运营或研发可以快速切换旧配置。2.2 组件与调用流程先看组件分工。组件在问责链路中的作用API 入口解析用户身份生成或透传 TraceId调用上下文携带用户、模型版本、场景码贯穿后续逻辑模型调网关统一处理模型协议的封装、超时、错误映射风险规则引擎判断输出是否需要人工复核或直接拒绝审计存储保存全量调用记录、审核记录和异常信息人工复核台展示风险记录允许标记通过或驳回一次调用的完整流程如下客户端调用/api/ai/chat请求头携带用户 ID。服务端生成全局 TraceId并构建当前请求的模型调用上下文。模型网关把业务请求翻译成模型 API 格式同时计时。返回结果后统一解析回答文本、token 消耗、耗时和错误码。风险规则根据输出内容标记风险级别。审计仓库把本次调用详情写入表。如果是高风险内容额外生成一条复核待办。这套流程的目的很清楚无论用户最终看到什么系统都能通过 TraceId 把请求和结果重新拉出来。2.3 审计数据模型要从字段开始设计审计数据模型不能等功能写完了再补。最晚在接口设计阶段就要确定需要留哪些字段。下面这张表是常见的最小字段集合。CREATE TABLE ai_audit_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, trace_id VARCHAR(64) NOT NULL COMMENT 全局追踪ID, user_id VARCHAR(64) NOT NULL COMMENT 发起用户, tenant_id VARCHAR(64) DEFAULT NULL COMMENT 租户或组织, scenario_code VARCHAR(32) NOT NULL COMMENT 场景编码, model_provider VARCHAR(128) DEFAULT NULL COMMENT 模型服务商或网关, model_name VARCHAR(128) NOT NULL COMMENT 模型名称, model_version VARCHAR(128) NOT NULL COMMENT 模型版本快照, system_prompt_md5 VARCHAR(32) DEFAULT NULL COMMENT 系统提示词MD5, messages_json JSON NOT NULL COMMENT 请求消息列表, response_json JSON DEFAULT NULL COMMENT 模型完整响应, input_tokens INT DEFAULT 0 COMMENT 输入token数, output_tokens INT DEFAULT 0 COMMENT 输出token数, total_credits DECIMAL(12,4) DEFAULT 0 COMMENT 本次消耗的点数或成本, duration_ms INT DEFAULT 0 COMMENT 模型调用耗时, risk_flag TINYINT DEFAULT 0 COMMENT 风险标记0正常1待审核2异常, review_status VARCHAR(16) DEFAULT NONE COMMENT 复核状态, reviewer VARCHAR(64) DEFAULT NULL COMMENT 复核人, error_code VARCHAR(64) DEFAULT NULL COMMENT 异常码, created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), KEY idx_trace_id (trace_id), KEY idx_created_at (created_at), KEY idx_user_id (user_id), KEY idx_model (model_name, model_version) );关键字段说明trace_id一个业务请求的全局唯一标识。它需要穿透代理、HTTP 服务、模型网关和消息队列而不是只在单个 JVM 里有效。model_version必须取调用时的版本快照不能去配置文件里现读。否则配置已经变化审计记录却不知当时用了哪一版。messages_json保存发送给模型的完整消息列表。这对事后复现异常非常有价值。total_credits不同供应商的计费单位不同这里先统一成一组数值。后文会说明 credits 在成本归因中的含义。生产环境中这张表建议放进独立审计库或日志系统避免和业务表抢数据库连接。如果写入量较大可以使用消息队列异步落库但需要在审计记录中保留“写入状态”字段防止丢失。3. 用 Spring Boot 搭一个带审计的 AI 调用服务3.1 环境准备与项目结构示例采用 Spring Boot 3.x、JDK 17、Maven。具体版本号以你所在企业维护的基线为准。项目并不绑定某个模型供应商 SDK通过RestClient调用一个兼容 OpenAI Chat Completions 协议的服务地址如果你接入私人网关或本地推理服务只需要改配置和解析逻辑。pom.xml中加入下面基础依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency目录结构如下src/main/java/com/example/aiaccount/ AiAccountApplication.java controller/AiChatController.java rpc/ModelGateway.java context/AiCallContext.java model/ModelCallResult.java service/AiChatService.java audit/AuditLogRepository.java audit/AuditRecorder.java risk/RiskRuleEngine.java src/main/resources/application.yml后面的代码按这个结构组织。模型网关层独立出来是为了让业务代码只依赖自己的调用上下文不直接依赖某个模型 SDK 的请求和响应对象。3.2 建立模型调用上下文滥用 Map 传参会造成字段随意传递后面审计记录时很容易漏字段。这里定义一个上下文对象。public record AiCallContext( String traceId, String userId, String tenantId, String scenarioCode, String modelName, String modelVersion, ListChatMessage messages ) { public static AiCallContext from( String traceId, String userId, String tenantId, String scenarioCode, String modelName, String modelVersion, String systemPrompt, String userMessage) { return new AiCallContext( traceId, userId, tenantId, scenarioCode, modelName, modelVersion, List.of( new ChatMessage(system, systemPrompt), new ChatMessage(user, userMessage) ) ); } } public record ChatMessage(String role, String content) { }如果知识库问答还会检索文档建议继续扩展这个 record增加retrieved_docs字段保存文档标识、片段内容或向量库集合名称。这样同一个答案是由哪些资料生成的就一目了然。3.3 请求入口和控制器控制器只做参数绑定和最小校验不写业务逻辑。RestController RequestMapping(/api/ai) public class AiChatController { private final AiChatService aiChatService; public AiChatController(AiChatService aiChatService) { this.aiChatService aiChatService; } PostMapping(/chat) public AiChatResponse chat( RequestHeader(X-User-Id) String userId, RequestHeader(value X-Trace-Id, required false) String traceId, RequestBody Valid ChatRequest request) { return aiChatService.chat(traceId, userId, request); } }这里有两个要点用户 ID 从请求头取。真实场景里请求头应该由企业 SSO、API 网关或认证服务校验后写入不能由前端随意赋值。TraceId 允许外部传但必须校验格式防止把外部不可控的字符串带进日志。更常见的是内部始终使用UUID生成外部传入值最多作为关联业务号。ChatRequest和响应可以定义成简单 recordpublic record ChatRequest( NotBlank String question, String conversationId ) { } public record AiChatResponse( String traceId, String answer, boolean highRisk ) { }3.4 模型网关代码设计模型网关负责把内部上下文翻译成模型 API 的请求体并统一返回结果。下面代码只用来展示思路实际项目的 JSON 结构要和模型服务端对齐。Component public class ModelGateway { private final RestClient restClient; public ModelGateway(Value(${ai.model.endpoint}) String endpoint) { this.restClient RestClient.builder().baseUrl(endpoint).build(); } public ModelCallResult call(AiCallContext ctx) { MapString, Object payload Map.of( model, ctx.modelName() : ctx.modelVersion(), temperature, 0.2, messages, ctx.messages().stream() .map(m - Map.of(role, m.role(), content, m.content())) .collect(Collectors.toList()) ); long start System.currentTimeMillis(); try { Map?, ? response restClient.post() .uri(/v1/chat/completions) .body(payload) .retrieve() .body(Map.class); long durationMs System.currentTimeMillis() - start; return parseSuccessResponse(ctx, response, durationMs); } catch (Exception ex) { long durationMs System.currentTimeMillis() - start; return ModelCallResult.failure(ctx.traceId(), MODEL_CALL_ERROR, ex.getMessage(), durationMs); } } }ModelCallResult承担统一返回结构字段至少包括public record ModelCallResult( String traceId, boolean success, String answer, Integer inputTokens, Integer outputTokens, Double totalCredits, Long durationMs, String errorCode, String errorMessage, Map?, ? rawResponse ) { public static ModelCallResult success(...) { ... } public static ModelCallResult failure(...) { ... } }不要把Map直接散落在业务代码中。模型服务商升级响应字段时只需要修改网关解析层服务和审计代码都不必跟着变。4. 把模型调用和审计日志串成一条链4.1 审计写入需要一个稳定接口不推荐在每个业务方法里手写INSERTSQL。更稳妥的方式是定义一个AuditRecorder业务代码只需传入上下文和结果。Component public class AuditRecorder { private final AuditLogRepository repository; public AuditRecorder(AuditLogRepository repository) { this.repository repository; } public void record(AiCallContext ctx, ModelCallResult result, int riskFlag) { AiAuditLog log new AiAuditLog(); log.setTraceId(ctx.traceId()); log.setUserId(ctx.userId()); log.setTenantId(ctx.tenantId()); log.setScenarioCode(ctx.scenarioCode()); log.setModelName(ctx.modelName()); log.setModelVersion(ctx.modelVersion()); log.setMessagesJson(toJson(ctx.messages())); log.setResponseJson(result.rawResponse() null ? null : toJson(result.rawResponse())); log.setInputTokens(result.inputTokens()); log.setOutputTokens(result.outputTokens()); log.setTotalCredits(result.totalCredits()); log.setDurationMs(result.durationMs()); log.setRiskFlag(riskFlag); log.setErrorCode(result.errorCode()); log.setCreatedAt(LocalDateTime.now()); repository.insert(log); } }异常信息也要记录。比如模型网关超时后如果重试成功追踪链路里需要看到最初失败了一次、超时时长多少、失败原因是什么否则后续成本归因和稳定性排查都缺少数据。4.2 服务层把流程串起来AiChatService是业务入口。它负责构建上下文、调用模型、执行风险规则、写审计日志最后返回响应。Service public class AiChatService { private final ModelGateway modelGateway; private final AuditRecorder auditRecorder; private final RiskRuleEngine riskRuleEngine; public AiChatService(ModelGateway modelGateway, AuditRecorder auditRecorder, RiskRuleEngine riskRuleEngine) { this.modelGateway modelGateway; this.auditRecorder auditRecorder; this.riskRuleEngine riskRuleEngine; } public AiChatResponse chat(String traceId, String userId, ChatRequest request) { String finalTraceId (traceId null || traceId.isBlank()) ? UUID.randomUUID().toString() : traceId; AiCallContext ctx AiCallContext.from( finalTraceId, userId, tenant-demo, kb_chat, knowledge-assistant, modelVersionProvider.currentVersion(), systemPromptProvider.currentPrompt(), request.question() ); ModelCallResult result modelGateway.call(ctx); int riskFlag riskRuleEngine.evaluate(result); auditRecorder.record(ctx, result, riskFlag); boolean highRisk riskFlag ! 0; return new AiChatResponse(finalTraceId, result.answer(), highRisk); } public String getVersion() { return modelVersionProvider.currentVersion(); } }在真实项目里modelVersionProvider.currentVersion()应该读取配置中心下发的模型版本变量而不是在代码里固定一个值。任何版本切换都要记录到独立变更表。4.3 参数化 SQL 写入审计表审计写入示例使用JdbcTemplate实现重点是不能用字符串拼接。Repository public class AuditLogRepository { private final JdbcTemplate jdbcTemplate; public AuditLogRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public void insert(AiAuditLog log) { jdbcTemplate.update( INSERT INTO ai_audit_log ( trace_id, user_id, tenant_id, scenario_code, model_name, model_version, system_prompt_md5, messages_json, response_json, input_tokens, output_tokens, total_credits, duration_ms, risk_flag, review_status, error_code, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , log.getTraceId(), log.getUserId(), log.getTenantId(), log.getScenarioCode(), log.getModelName(), log.getModelVersion(), log.getSystemPromptMd5(), log.getMessagesJson(), log.getResponseJson(), log.getInputTokens(), log.getOutputTokens(), log.getTotalCredits(), log.getDurationMs(), log.getRiskFlag(), log.getReviewStatus(), log.getErrorCode(), log.getCreatedAt()); } }注意messages_json和response_json在 MySQL 中属于 JSON 类型如果数据库连接把JsonNode或字符串直接传入驱动行为可能不同。这里在写入前统一转成字符串数据库再作为 JSON 类型解析兼容性更好。但生产环境建议单独验证所用的 MySQL JDBC 版本对 JSON 字段的处理方式。4.4 风险规则和人工复核风险规则不能只看最后一句回答至少把异常、空答案、超范围回答都纳入考虑。最简版本如下Component public class RiskRuleEngine { private static final ListString RISK_KEYWORDS List.of(__RISK_1__, __RISK_2__); public int evaluate(ModelCallResult result) { if (!result.success()) { return 2; } String answer result.answer() null ? : result.answer(); if (answer.isBlank()) { return 2; } for (String keyword : RISK_KEYWORDS) { if (answer.contains(keyword)) { return 1; } } return 0; } }实际项目里RISK_KEYWORDS不能硬编码需要由业务方在后台配置并周期性评审。上面的占位符是为了说明规则引擎的形状不是让你直接照抄。当风险标记为 1 或 2 时建议额外写一张复核任务表供运营人员处理。不要只靠 DBA 去 AI 日志表里翻数据。CREATE TABLE ai_review_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, audit_log_id BIGINT NOT NULL, trace_id VARCHAR(64) NOT NULL, risk_level TINYINT NOT NULL, status VARCHAR(16) DEFAULT PENDING, assignee VARCHAR(64), comment_text VARCHAR(512), handled_at DATETIME(3), INDEX idx_status (status), INDEX idx_trace_id (trace_id) );业务上如果高风险记录没有完成复核甚至可以不让最终回答展示给用户。这也是 Accountability 落地的关键做法高风险内容不允许自动发布。5. 模型版本、参数和成本让“谁负责”有证据5.1 模型配置外置到配置中心很多团队把模型名称和温度写在代码常量里导致版本升级困难也无法追踪线上行为与配置之间的关系。比较好的做法是把配置外置例如下面的application.ymlai: model: endpoint: http://localhost:8000/v1/chat/completions name: knowledge-assistant version: 20250101 temperature: 0.2 max-tokens: 1024 credits-per-1k-input-tokens: 0.01 credits-per-1k-output-tokens: 0.02模型版本不是随便写的字符串。它必须与模型推理服务端实际部署的权重保持一致
返回列表