如果你维护过线上大模型应用,大概率遇到过这种尴尬:离线 demo 看起来很好,灰度一放量,真实用户立刻把系统打出各种边界条件。
同一个“帮我总结一下”请求,测试集里是 800 字文章,线上可能是 6 万字会议纪要;同一个客服问答,测试集里是标准问题,线上可能夹着截图 OCR、错别字、方言缩写和情绪化追问;同一个 Agent 工具调用,测试集里只需要查一次库,线上可能连续调用 7 个工具,还会被用户中途改目标。
传统软件上线前,我们有单元测试、集成测试、压测、预发验证;LLM 应用上线前,很多团队只有一组手写 eval case。问题是,手写 case 往往覆盖的是“我们想得到的风险”,真实流量覆盖的是“用户真的会怎么用”。这两者差距很大。
这篇文章聊一个越来越实用的工程模式:LLM 影子流量回放。它不是简单把线上请求复制给新模型,而是一套围绕采样、脱敏、上下文冻结、候选版本回放、语义评测、成本预算和发布闸门的工程系统。
我的结论先放前面:
- 影子流量回放不替代离线 eval,但能补上离线 eval 最缺的“真实分布”。
- 它最适合验证模型替换、Prompt 改版、RAG 策略变更、工具调用策略变更和安全策略升级。
- LLM 回放的核心难点不是“怎么重放 HTTP 请求”,而是“怎么让一次重放具备可比较性、可解释性和可回滚性”。
- 如果没有脱敏、采样、成本上限和发布闸门,影子流量很容易从质量工程变成新的线上风险。
1. 为什么普通 eval 不够用
LLM 应用的失败通常不是单点失败,而是组合失败。
一个 RAG 客服机器人看起来只是“回答错了”,实际链路可能是:用户问题被错误改写,检索召回了相似但过期的文档,rerank 把真正答案排到后面,Prompt 中的约束没有被遵守,输出解析器又把“无法确定”处理成了正常答案。
如果只看最终回答,你会觉得模型不行;如果只测模型,你会错过检索链路;如果只测检索,你又看不到模型在真实上下文下的行为。
Braintrust 的 LLM evaluation 指南把评测拆成离线评测、在线评测、组件级评测、端到端评测。这个划分很有用:离线评测像传统测试套件,在线评测像生产监控;组件级评测定位问题,端到端评测判断用户目标是否达成。
但在生产系统里,还有一个常被忽略的阶段:候选版本已经准备上线,但还不应该被用户看见。
这时你需要一种 0% 用户可见的验证方式。影子流量回放就是放在这个位置。
2. 影子流量回放到底是什么
在传统后端系统里,流量录制回放常用于回归测试:录制生产真实请求和依赖响应,在测试环境里重放,比较接口返回值和中间链路。得物技术的流量回放实践里就提到,这类系统的核心价值是把生产真实数据转化成可复用、可执行的回放流量,用来验证接口返回和链路行为。
迁移到 LLM 应用后,影子流量回放可以定义成:
从生产请求中按策略采样,经过脱敏与上下文冻结后,在用户不可见的环境中重放到候选模型、候选 Prompt 或候选链路,并用自动评测与人工抽检比较候选版本和线上版本的质量、延迟、成本与安全表现。
它有三个关键点:
- 用户不可见:线上用户仍然只看到当前稳定版本的结果。
- 真实输入分布:请求来自生产,而不是团队手写的理想 case。
- 可比较:同一批请求要能被线上版本和候选版本同时评估,产出可解释 diff。
不要把它和 A/B 测试混在一起。A/B 测试会让部分用户真实看到候选结果,能测用户满意度、转化率、追问率;影子流量不影响用户,适合上线前兜底。我的推荐顺序是:离线 eval → 历史流量回放 → 线上影子流量 → 小比例 A/B → 灰度放量。
3. LLM 回放和普通接口回放有什么不同
普通接口回放关心的是字段 diff、状态码、异常、性能。LLM 回放当然也关心这些,但更麻烦的是下面 6 件事。
3.1 输出不是字节级稳定的
两个等价回答可能完全不同。比如“可以退款,但需要订单未发货”和“订单未发货时支持退款”语义一致,字符串 diff 却很大。因此 LLM 回放不能只做 exact match,需要结构化断言、语义相似度、规则检查和裁判模型组合。
3.2 上下文会漂移
RAG 系统今天检索到的文档,明天可能已经更新。工具调用今天查到的库存,明天也会变。因此回放时必须冻结上下文:检索结果、工具响应、用户画像、实验参数、系统 Prompt 版本都要进入 replay bundle。
3.3 成本是测试预算的一部分
普通接口回放多跑几万条,成本主要是机器资源;LLM 回放多跑几万条,可能直接产生模型调用费用。没有采样和预算闸门,回放系统会成为新的成本黑洞。
3.4 隐私风险更高
用户输入可能包含姓名、手机号、订单号、合同内容、病历、内部代码。影子链路如果把这些数据发给候选模型或第三方评测服务,风险会被放大。所以生产流量进入回放系统前,必须脱敏、分级和审计。
3.5 裁判也会犯错
用另一个模型当 judge 很方便,但 judge 本身也有偏差。正确做法不是迷信一个综合分,而是把评测拆成多个维度:事实一致性、指令遵循、格式合法性、安全合规、拒答是否合理、成本和延迟。
3.6 失败要能定位到组件
候选版本得分下降时,你需要知道是检索差了、Prompt 差了、模型差了、工具策略差了,还是输出解析器变了。否则回放只能告诉你“别上线”,无法告诉你“为什么别上线”。
4. 一套可落地的架构
我建议把 LLM 影子流量回放拆成 7 个模块:
| 模块 | 职责 | 常见坑 |
|---|---|---|
| Traffic Sampler | 从生产请求采样,按场景、用户层级、风险标签分层 | 只随机采样会漏掉低频高风险场景 |
| Redactor | 脱敏 PII、密钥、合同号、内部代码 | 只做正则不够,要结合字段语义 |
| Context Freezer | 冻结检索结果、工具响应、Prompt 版本、参数 | 没冻结上下文会导致 diff 不可解释 |
| Replay Runner | 控制并发、预算、重试,把请求打到候选链路 | 直接复制线上 QPS 会把候选服务打挂 |
| Evaluator | 规则 + 语义 + judge + 人工抽检 | 一个总分无法解释质量下降 |
| Diff Explorer | 展示线上版本与候选版本的差异 | 没有 case drill-down,研发无法修 |
| Release Gate | 根据质量、成本、延迟、安全阈值决定是否放量 | 没闸门就会变成“看个报表继续上线” |
一条请求进入 replay bundle 后,至少应该包含这些字段:
{"request_id":"req_20260608_001","scenario":"refund_policy_qa","risk_tags":["money","policy"],"user_input_redacted":"我的订单 [ORDER_ID] 还没发货,可以退款吗?","prod_trace":{"prompt_version":"support-v18","model":"prod-model-a","retrieved_docs":["doc_refund_policy_v12"],"tool_results":[{"name":"order_status","result":"not_shipped"}],"output":"订单未发货时支持退款,你可以在订单页申请。"},"candidate":{"prompt_version":"support-v19","model":"candidate-model-b","temperature":0.2}}注意这里保存的是脱敏后的输入和引用 ID,不应该把原始敏感内容随意落盘。对高风险字段,可以只保存 hash、标签或加密后的值,由受控环境临时解密。
5. 一个最小可运行的回放评测器
下面是一个简化版 Node.js 示例。它不调用真实模型,而是模拟线上版本和候选版本输出,重点展示 replay runner 和 evaluator 的结构。
// replay-eval.mjsconstcases=[{id:'case-1',scenario:'refund_policy_qa',riskTags:['money','policy'],input:'我的订单还没发货,可以退款吗?',expectedFacts:['未发货支持退款','订单页申请'],prodOutput:'订单未发货时支持退款,你可以在订单页申请。',candidateOutput:'一般可以退款,建议联系客服处理。'},{id:'case-2',scenario:'invoice_qa',riskTags:['finance'],input:'电子发票多久能开?',expectedFacts:['支付后','24小时内'],prodOutput:'支付完成后通常 24 小时内可开具电子发票。',candidateOutput:'支付完成后通常 24 小时内可开具电子发票。'}];functionfactScore(output,expectedFacts){consthit=expectedFacts.filter(f=>output.includes(f)).length;returnhit/expectedFacts.length;}functionriskPenalty(output,riskTags){if(riskTags.includes('money')&&/一般|可能|联系客服/.test(output))return0.25;return0;}functionevaluate(row){constprod=factScore(row.prodOutput,row.expectedFacts)-riskPenalty(row.prodOutput,row.riskTags);constcand=factScore(row.candidateOutput,row.expectedFacts)-riskPenalty(row.candidateOutput,row.riskTags);return{id:row.id,scenario:row.scenario,prodScore:Number(prod.toFixed(2)),candidateScore:Number(cand.toFixed(2)),delta:Number((cand-prod).toFixed(2)),gate:cand>=prod-0.05?'pass':'block'};}constreport=cases.map(evaluate);console.table(report);constblocked=report.filter(r=>r.gate==='block');if(blocked.length){console.error(`release blocked:${blocked.length}regression case(s)`);process.exitCode=1;}在本地跑这个脚本,会得到类似结果:
case-1 refund_policy_qa prod=1.00 candidate=-0.25 delta=-1.25 gate=block case-2 invoice_qa prod=1.00 candidate=1.00 delta=0.00 gate=pass这个 toy evaluator 很粗糙,但它体现了一个重要原则:评测器应该尽量可解释,而不是只给一个漂亮分数。当 case-1 被阻断时,研发能看到原因:候选回答少了“未发货支持退款”和“订单页申请”两个关键事实,还在资金相关问题上用了含糊措辞。
真实系统里,可以把 evaluator 拆成四层:
- 硬规则:JSON schema、必填字段、禁用词、安全拒答、引用是否存在。
- 业务断言:退款政策、发票时效、风控边界、工具调用前置条件。
- 语义评测:答案是否覆盖关键事实,是否与证据矛盾。
- 人工抽检:对高风险场景、低置信度 case、分歧 case 做人工复核。
6. 采样策略:不要只随机抽样
很多团队第一次做影子流量,最容易写出这种逻辑:从生产日志里随机抽 1%。这当然比没有强,但远远不够。
LLM 应用的风险分布通常是长尾的:高频问题未必高风险,低频问题反而可能涉及资金、合规、医疗、法律、隐私、删除数据等敏感场景。
更合理的采样策略是“分层 + 配额”:
sampling_policy:default_rate:0.5%strata:-name:high_risk_moneymatch:risk_tags contains moneyrate:20%max_per_day:2000-name:tool_callingmatch:trace.tool_calls_count>0rate:5%max_per_day:3000-name:long_contextmatch:input_tokens>12000rate:10%max_per_day:1000-name:negative_feedbackmatch:user_feedback in[thumb_down,complaint]rate:50%max_per_day:1000这套策略的目标不是还原整体流量分布,而是让回放数据覆盖上线风险。上线决策可以同时看两类指标:按真实流量加权的整体指标,以及高风险分层的局部指标。
7. 脱敏:别把影子系统做成数据泄漏系统
影子流量回放最容易被低估的是数据治理。因为它看起来只是“内部测试”,实际却复制了生产输入、上下文和模型输出。
我建议至少做三件事:
- 字段级脱敏:手机号、邮箱、身份证、订单号、地址、银行卡、密钥、cookie、内部 token 必须处理。
- 语义级脱敏:用户可能在自然语言里写“我叫张三,电话是……”。这类不能只依赖字段名,需要正则 + NER + 大模型辅助标注组合。
- 分级回放:低敏 case 可以进入通用候选模型;高敏 case 只能在受控环境回放;极高敏 case 只保留统计指标,不进入影子链路。
脱敏不是越狠越好。把所有实体都替换成[MASK]后,模型行为可能失真。更好的方式是类型保持:手机号换成另一个合法格式手机号,金额换成同量级金额,城市换成同级城市。这样既降低风险,又保留输入结构。
8. 评测指标:上线闸门应该看什么
一个可执行的 release gate 至少要覆盖 5 类指标。
| 指标 | 示例阈值 | 阻断原因 |
|---|---|---|
| 质量 | 高风险场景胜率不低于线上版本 98% | 候选版本在关键任务退化 |
| 安全 | 严重安全违规 case = 0 | 不能用平均分掩盖红线问题 |
| 格式 | 结构化输出解析成功率 ≥ 99.5% | 下游系统依赖格式稳定 |
| 成本 | 单请求平均成本上涨 ≤ 20% | 防止模型替换导致预算失控 |
| 延迟 | P95 延迟上涨 ≤ 15% | 用户体验不能明显变差 |
这里要特别强调:不要只看平均分。如果候选模型在闲聊场景提升 10%,但在退款、删除账号、合同解释等高风险场景退化 2%,我会阻止上线。
一个更实用的闸门写法是:
release_gate:block_if:-safety.critical_violations>0-high_risk.win_rate < 0.98-schema.parse_success_rate < 0.995-cost.avg_per_request>baseline.cost.avg_per_request * 1.2-latency.p95>baseline.latency.p95 * 1.15require_manual_review:-judge_disagreement_rate>0.15-unknown_intent_rate>baseline.unknown_intent_rate * 1.3这样的 gate 比“综合分 85 分以上可以上线”更像工程系统。
9. 从影子回放到灰度发布
影子流量通过,不代表可以全量发布。它只说明候选版本在用户不可见的真实输入上没有明显退化。下一步还需要小比例 A/B 或灰度,因为有些信号只有用户看到结果后才会出现:用户是否追问、是否复制答案、是否点赞、是否投诉、是否完成任务。
我的发布节奏通常是:
- 历史回放:过去 7 天或 30 天采样数据,验证候选版本不明显退化。
- 线上影子:复制当前实时流量,观察 24-72 小时,覆盖工作日和高峰期。
- 1% 灰度:只给低风险用户或内部账号可见,实时监控质量和反馈。
- 5%-20% 放量:开始看用户行为指标,继续保留自动回滚。
- 全量:保留影子对照一段时间,用于发现长尾退化。
这套流程看起来慢,但对高风险 LLM 应用来说,它比“周五下午直接切模型”便宜太多。
10. 常见失败模式
最后列几个我见过的坑。
坑 1:只保存输入,不保存上下文。结果候选版本看起来退化,其实是检索文档变了。解决:保存 doc id、版本、片段、工具响应和 Prompt 版本。
坑 2:只用模型 judge,不做业务断言。judge 觉得回答自然流畅,但业务规则错了。解决:高风险业务规则必须写成硬断言。
坑 3:采样没有风险分层。随机样本里 80% 是低风险闲聊,结论很好看,上线后资金场景翻车。解决:分层采样和分层报表。
坑 4:没有成本保护。候选链路多了一次 rerank 和一次 judge,回放 10 万条后账单爆炸。解决:预算上限、并发控制、分阶段扩大样本。
坑 5:diff 不可读。报表只有分数,没有失败 case、证据和 trace。解决:每个失败 case 必须能 drill down 到输入、上下文、候选输出、断言失败原因。
结语
LLM 应用的上线质量,不应该只靠“我感觉这个 Prompt 更好”。
手写 eval 能覆盖已知风险,线上监控能发现已发生的问题,而影子流量回放填补的是中间层:在用户看见候选版本之前,用真实请求分布提前暴露退化。
如果你的团队已经开始频繁替换模型、调整 Prompt、升级 RAG 或改 Agent 工具策略,我建议尽早把影子回放纳入发布流程。先不用做得很复杂:从 1000 条脱敏历史请求、10 个高风险场景、5 个硬规则断言开始,就能比“凭感觉上线”稳很多。
真正成熟的 AI Engineering,不是把模型接进产品就结束,而是让每一次模型和 Prompt 的变化,都能被记录、回放、比较和阻断。
参考资料
- Evaluation-Driven Development and Operations of LLM Agents:https://arxiv.org/html/2411.13768v3
- How to Roll Out New LLMs Safely Using Shadow Testing:https://www.codeant.ai/blogs/llm-shadow-traffic-ab-testing
- What is LLM evaluation? A practical guide to evals, metrics, and regression testing:https://www.braintrust.dev/articles/llm-evaluation-guide
- 订单流量录制与回放探索实践 - 得物技术:https://tech.dewu.com/article?id=22