模型推理服务上线之后,团队的第一反应通常是盯监控面板:QPS、延迟、错误率、显存占用。这些东西当然重要,但跑过一段时间你就会明白,真正值钱的不是那些聚合指标,而是每一次推理本身留下的完整记录——也就是标题里说的"推理历史数据"。我见过不少项目摸黑跑了好几个月,直到某天产品负责人跑来问:为什么最近回答质量好像变差了?想把历史样本整理成测试集的算法同学,翻遍日志也只找到几段残缺片段,很多请求当时到底用了什么Prompt、什么模型版本、什么参数,完全无据可查。
这篇是"大模型落地"系列的第3篇。前两篇我们解决了怎么把推理服务稳定跑起来、怎么搭基础Prompt结构,这一篇专门聊一件事:怎么把每一次推理的输入、输出以及周边信息,变成一套可查询、可回溯、可复用的历史数据资产。
1. 为什么说推理历史是模型上线后的第一笔资产
1.1 没有历史数据的时候,团队有多被动
我拿真实场景说。有一天运维同事跑过来说:今天生产环境的回答平均延迟从1.2秒涨到了2.8秒,是不是模型服务有问题?你点开监控,确实延迟涨了,但说不清是哪一类请求涨的、是长文本还是长上下文、是哪条Prompt路径。如果你从上线第一天就开始记录每个请求的token数、上下文长度、模型版本,这个问题几分钟就能定位到"某个场景的上下文长度近期翻倍了"。
更常见的是质量问题。用户投诉"最近回答变笨了",你复现不了——因为你根本不知道用户当时问的原文是什么、带没带历史上下文、命中了几段检索知识、走了哪套Prompt模板。没有历史数据,质量争议就只能是两边各说各话。
还有一个所有人都躲不开的问题:成本。大模型推理是按token计费的。某个业务方悄悄把max_tokens调大了一档,或者某个场景的上下文拼接越来越长,一个月下来账单多出好几万,如果没有推理历史,连这张账单都没法解释。
1.2 推理历史数据会喂给谁
想清楚字段设计之前,先搞清楚数据将来会被谁消费。我习惯列一张表:
| 消费者角色 | 关心的问题 | 需要的数据 |
|---|---|---|
| 研发/算法 | 模型行为是否回归、回答质量波动 | 完整请求、响应、Prompt模板版本、模型版本 |
| 运维/SRE | 延迟、错误、资源消耗异常 | 耗时、状态码、token数、模型并发 |
| 产品/数据分析 | 用户真实需求是什么、功能使用情况 | 业务标签、query文本、会话ID |
| 财务/成本管理 | 钱花到了哪里 | 场景ID、模型名称、token消耗、缓存命中 |
| 算法团队 | 微调数据、评测集、Prompt迭代 | 高质量样本、人工反馈、历史对比数据 |
同一份原始数据,五个角色可能是五种完全不同的查询方式。所以这里的原则是:字段设计必须从"消费需求"反推,而不是从日志顺手捞一把。
2. 一条推理记录到底该存哪些字段
2.1 请求侧:没有入参就没有复现
排错最怕的就是"当时到底发了什么"。请求侧至少要有这些:
request_id/trace_id:全局唯一ID,业务日志、网关日志、推理记录靠它关联user_id、scene_id:定位是谁在什么场景发起的请求,注意这里建议只存内部ID,不要明文存手机号等真实身份信息model_name+model_version:这两个必须分开存。很多团队只存模型名,导致无法区分"同一个模型换了权重"前后的行为差异- 推理参数:
temperature、top_p、max_tokens、presence_penalty这类采样参数。同一套Prompt,temperature从0.1改成0.9,输出风格天差地别,不记下来就白调试了 - 完整的Prompt内容:包括
system_prompt、历史会话、用户query。如果走的是RAG,还需要记录下来检索到的文档片段 prompt_template_version:你用的是第几版Prompt模板。没有这个字段,后面做Prompt迭代对比时会非常痛苦
2.2 响应侧:除了回答内容,还要记下"代价"
响应内容自然要存,但有几个字段新手特别容易漏:
finish_reason:是stop正常结束,还是length被截断,还是别的原因。很多时候模型回答看起来"答非所问",其实是max_tokens设小了,内容被硬生生截断prompt_tokens、completion_tokens:分开记。前者能反映上下文拼接是否膨胀,后者能反映输出长度是否失控latency_ms和first_token_latency_ms:有些场景用户感知到"慢",其实是首token慢,而不是整段慢ttft(time to first token)这类指标,以及是否命中缓存
响应耗时和token数是成本分析、性能分析的两根柱子,缺一个都算不清楚账。
2.3 关联字段和标签:让数据可以被筛选串起来
我强烈建议在写入时顺手打几个标签,而不是等分析时再算:
status:成功、失败、超时、降级is_flagged:这条样本是否被标记为异常或敏感feedback:用户在界面上有没有点"有帮助/没帮助",这是后期筛选微调样本的重要信号cache_hit:是否命中了上下文缓存,这直接影响成本核算
一个比较完整的记录长这样:
{ "request_id": "req_20250601_00012345", "scene_id": "customer_service", "model_name": "chat-model", "model_version": "v2.3.1", "template_version": "prompt_20250520", "temperature": 0.3, "max_tokens": 2048, "context": { "system": "...", "history": ["...", "..."], "query": "我的订单显示已签收但没收到货", "retrieval": ["文档片段A", "文档片段B"] }, "output": "您好,抱歉给您带来困扰...", "usage": { "prompt_tokens": 1280, "completion_tokens": 156 }, "latency_ms": 2140, "first_token_latency_ms": 780, "finish_reason": "stop", "status": "success", "cache_hit": false, "feedback": null, "timestamp": "2025-06-01T10:23:45Z" }值得一提:context.history大段存储会吃很多空间,但这是复现问题的最低要求。如果存储压力大,可以对早期历史做截断,只保留最近几条会话,同时把retrieval片段做截断。取舍规则要提前定,不要等库里塞满垃圾才想起来清理。
3. 从调用到入库:一条低调的数据链路
3.1 埋点位置:网关层比业务层更可靠
埋点放哪里决定了数据完整率和维护成本。我见过放在业务代码里的做法,优点是字段可以打得非常细,但问题也很明显:每个业务团队改起来风格不同,有的忘了上报,有的把一个字段塞了三种含义,最后入库的数据根本没法统一分析。
更可靠的做法是放在统一的网关层或者统一推理SDK里。只要请求经过这里,就能拿到完整的入参出参、耗时、token统计。业务字段可以靠接口透传或者header传递进网关,统一在网关侧落一条标准记录。特别是多业务线共用同一套模型服务的团队,这一步能省掉后面90%的争吵。
3.2 写入方式:绝不能在推理主链路上同步落库
新手最常见的错误是在模型调用结束之后,同步把记录写进数据库,然后才发现:数据库慢了一下,整条接口也跟着慢了。数据采集不能卡业务主链路,这是底线。
正确思路是解耦:推理完成后,把记录塞进内存队列或者本地文件,由后台任务异步批量写入。比如在Python服务里可以用queue.Queue加一个后台消费者,或者用本地日志文件追加,再由小任务定时上传到数据仓库。整个过程只需要保证一件事:数据最终一致,不需要强一致。
如果团队已经有消息队列基础设施,直接把记录投递到消息队列是最省心的,下游消费、清洗、入库各管各的。
3.3 热库与冷库分开,查询和存储才有解
把推理历史全塞进同一张表,前期很爽,后期很惨。我见过有人把半年数据放在一张PostgreSQL表里,每次查带时间范围的分析语句跑几十秒,把在线业务都拖累了。
建议按时间把存储拆成两层:
- 热库:保留最近7到30天,供线上排查、质量回归、日常报表使用。查询要求快,可以用ClickHouse、Elasticsearch这类列式或倒排引擎
- 冷库:超过30天的数据归档到对象存储,按天或按月做分区,存成Parquet等列式格式。平时不参与在线查询,需要做长周期分析时再按需加载
热库和冷库的查询接口最好保持接近,这样上层分析逻辑不用为存储切换写两套。
3.4 小型团队能直接抄的起步方案
如果你的团队只有十几个人的规模,还没有专门的数据平台,别急着上大数据全家桶。一个务实的起步组合是:
- 网关或者统一SDK侧把推理记录写成JSON行,追加到本地文件
- 定时任务(比如每分钟或每五分钟)把增量文件推送到一台PostgreSQL数据库,采用JSONB字段存整条记录
- 数据库再按天做分区,建立一个按时间、场景、模型名查询的简单API
这个方案能撑住每日几十万次的推理量,而且维护成本极低。等真到了每日千万级,再考虑迁移到ClickHouse那套体系也来得及。
4. 历史推理数据能挖出哪些实用结论
4.1 把历史提问变成回归测试集
这是推理历史最直接的用法。很多团队想测新模型版本效果,却苦于没有真实测试数据。现在你手里有几个月真实的用户提问和当时的回答,虽然模型输出没法用自动化断言来判对错,但可以做"回放式对比":把历史query按照当时的参数配置重新喂给新版本模型,人工对比一批样本,看新版本是不是比旧版本更合理。
操作上我会这样处理:
- 从历史数据里按场景分层抽样,保证每个业务场景都有覆盖
- 每类场景抽50到100条,凑成几百条的回归集
- 固定一套Prompt模板版本和参数,新老模型各跑一遍
- 人工按"更好、差不多、更差"三档打分,算出回归比例
这个方法不需要额外标注团队,一次投入半天人力就能给上线决策提供依据。
4.2 追踪Prompt改版的真实效果
很多团队改Prompt靠直觉,改完之后也不知道是变好还是变坏。有了template_version字段之后,你可以直接对比同一个query在不同模板版本下的输出。
举个例子,如果你想验证"新Prompt模板是否让回答更简洁",可以先找出在两个版本下都出现过的那批query(比如线上模板从v1切到v2之后,同一用户问题再次出现),然后人工抽查或者用长度统计、关键词覆盖做粗筛。
SELECT template_version, COUNT(*) AS cnt, AVG(completion_tokens) AS avg_len, AVG(CASE WHEN feedback = 'up' THEN 1 ELSE 0 END) AS up_rate FROM inference_history WHERE scene_id = 'customer_service' AND timestamp >= now() - INTERVAL '30 days' GROUP BY template_version;只要同一场景的模板版本是分阶段放量的,这批数据就能告诉你哪个模板在真实流量下的表现更好,而不只是评测集上的几个分数。
4.3 算清楚钱花在哪、慢在哪
成本分析是老板最关心的问题,也是最容易被忽视的。用历史数据,你可以按天、按场景、按模型维度汇总token消耗和延迟:
SELECT scene_id, model_name, SUM(prompt_tokens) AS total_prompt, SUM(completion_tokens) AS total_completion, AVG(latency_ms) AS avg_latency, PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_latency FROM inference_history WHERE timestamp >= now() - INTERVAL '7 days' GROUP BY scene_id, model_name ORDER BY total_prompt + total_completion DESC;这张表一出来,你往往能发现80%的成本集中在某一个场景,而那个场景的Prompt上下文拼得特别长。这时候优化空间就出来了:缩短上下文、增加缓存命中、或者给该场景换一个更小的模型。
4.4 筛出值得微调或者蒸馏的样本
等你想做微调或者用小模型蒸馏大模型的时候,推理历史就是现成的数据矿。但直接全部拿去训练是不行的,历史数据里掺杂了大量低质量样本。我的筛选思路是:
- 优先选用户明确给过正向反馈的样本,这类样本说明真实用户认可了回答
- 选出高频意图的query,这类样本训练价值高,能让模型在常见问题上更稳
- 做近似去重,避免同一条query在训练集里出现几百次
- 人工抽检一轮,把政治敏感、涉隐私、明显错误的样本剔除
这个过程可以用SQL把候选集拉出来,再走一遍人工审核流。没有人工环节的微调数据集,基本都是在埋雷。
5. 采集推理历史时踩过的那些坑
5.1 数据量膨胀远比想象中快
推理历史的存储膨胀速度超出很多人的预期。算一笔账:假设每天10万次请求,平均每次总token数2000,一天就是2亿token的文本,换算成存储,光原文大概在几百MB到1GB之间,加上索引、JSON结构冗余、副本,一个月轻松攒下几十GB。如果是多场景、多模型,这个量级还会再翻几倍。
应对手段无非两个方向:一是设置保留周期,热库30天、冷库12个月,超出自动淘汰;二是做采样存储——明细数据全量进冷库,热库只保留分析必需的字段加上抽样明细。
5.2 敏感内容进仓库之前必须先处理
这是我最想强调的一点。用户发给模型的问题可能包含手机号、地址、收入等隐私信息,如果你把原始文本完整落库,等于主动制造了一个敏感数据仓库。这类数据的访问一旦失控,比模型本身的安全性风险更大。
我的习惯是入库前先跑一道脱敏流程:手机号、身份证、邮箱这类模式明显的字段直接用正则替换成占位符;涉及文本的,先做一轮敏感词或分类器检测,命中的记录标记为is_flagged,并且限制访问权限。明文只在必要的情况下短时间保留,分析需要时再按权限申请解密查看。
5.3 字段版本和代码版本对不上
推理历史是一个会持续演化的数据结构。今天加了RAG检索片段,明天加了缓存标记,后天把某个字段类型改了。如果你一开始没做版本控制,三个月后回看历史数据,会出现同一张表里,有的记录有retrieval字段,有的记录没有,有的model_version是空字符串,还没法跟代码版本对应上。
比较省心的做法是:在记录最外层加一个schema_version,每次结构变化就递增,并且保留一份"每个版本字段含义"的文档。即使现在做得不完美,也比没有任何版本信息强得多。
5.4 采了数据不等于能查
最后这个坑很隐蔽:数据是存下来了,但检索方式完全没设计。历史数据里query是自然语言文本,你用SQL的LIKE '%某某%'去搜,量一大就会卡死,更别说语义检索了。如果你明确知道后续要按语义查历史问题,就该在热库引入向量检索能力;如果只需要按场景、时间、状态做结构筛选,那普通数据库加合理索引完全够用。
别一上来什么都想要,先想清楚未来三个月到底会怎么查这些数据,再决定要不要上重型检索组件。
6. 落地推理历史数据的一些个人建议
写了这么多,最后分享几条我自己实操层面最想保留的经验。
第一,从第一天就做最小可用采集,别等架构齐全再补。哪怕只是网关日志里捞JSON字段存进PostgreSQL,也好过上线一个月之后两手空空。历史数据是补不回来的,错过就是永久缺失。
第二,字段命名规范要像代码规范一样对待。model_version就叫model_version,别叫version也别叫m_v,不然半年之后你盯着字段表发呆。
第三,定期做"历史回放演练"。我大概每个季度会把最近三个月的真实query抽几百条,对着最新模型跑一遍,不只是看正确率,还看延迟分布、token消耗、异常样本的变化。这个习惯帮我提前发现过好几次生产模型的质量下滑。
第四,给数据加生命周期意识。每一类数据进来的时候,就应该知道它保留多久、谁来访问、过期后怎么清除。这不是合规部门的事,是每个做模型工程的人应该默认带上的职业习惯。
推理历史数据说到底,就是把模型的每一次行为变成组织可以复用的知识。它不性感,甚至有点琐碎,但长期看,它带来的调试效率、成本节省和质量可追溯性,往往是团队最难被替代的竞争力。