ARTICLE DETAIL

资讯详情

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

大模型应用监控实践:从成本追踪到性能分析的全链路搭建

大模型应用监控实践:从成本追踪到性能分析的全链路搭建 这几个月我一直在折腾一件事把一个上线一年多、每天上万次调用的大模型应用从“出问题了才知道”改造成“账单和性能都能随时拆开看”的状态。起因很直接我们团队的智能客服接入了 RAG 知识库市面上主流模型基本都试了一遍结果某个月账单比上个月涨了快一半业务那边只问了一句“钱花在哪了”我愣是答不上来。从那天起我开始动手搭一套企业级的 LLM 监控系统核心解决两件事成本追踪和性能分析。这篇文章不打算讲理论框架就是记录我们从零开始怎么定义监控事件、怎么设计成本模型、怎么埋点入库、怎么把性能数据和告警落到线上以及在过程中踩过的一系列坑。适合两类人看一类是正在做大模型应用、但还停留在“看控制台”阶段的团队另一类是已经用了第三方监控工具却总觉得数据不够准、不够细、没法做成本归因的同行。我会把关键设计、表结构、SQL、伪代码都放出来方便直接参考。1. 为什么非要自己搭监控系统1.1 一个失控账单引出的需求先说背景。我们做的产品是一个面向电商场景的智能客服用户咨询进来之后系统先做意图识别、召回知识库片段再把上下文一起塞给大模型生成回复。最初只接了一个模型后端逻辑也比较简单问题集中在回复质量上没人关心延迟和成本。等后面接入了多模型路由、加了工具调用和更复杂的 Prompt 模板问题就变了。有一天财务把账单发过来说这个月模型 API 费用比上个月多了 40%。我第一反应是不是并发上来了实际上业务量只增长了 10% 左右。去翻代码才发现某个版本迭代里为了提升回答准确率system prompt 里加了一大段角色设定和输出格式说明同时又给工具参数加了大量描述导致每请求的输入 token 从 800 左右涨到了 2400 多。模型没有变问题没有变token 量翻了差不多三倍。这不是个例。在我接触过的几个团队里大模型应用的成本失控大多不是流量涨上来的而是 Prompt、工具定义、知识库检索结果在悄悄变重。没有监控你就只能等账单出来再复盘等于踩着刹车去看后视镜。1.2 现成监控工具为什么不够用一开始我也没打算完全自研市面上像 Langfuse、Helicone 这类开箱即用的监控工具确实能解决一部分问题尤其是实验和调试阶段看个 token 消耗、看个链路 trace 都很方便。但在生产环境里跑了小半年我发现它们有几个绕不过去的限制。第一数据主权问题。企业内部的请求日志里往往带着用户 ID、订单号、商品信息甚至一些客服会话的原始内容。走第三方平台意味着这些敏感数据要传到厂商标注的服务器合规这一关就过不去。就算有私有化部署版本运维和升级也要花不少人力小团队扛不住。第二业务标签和成本归因不灵活。第三方工具记录的是一层“模型调用”但它不知道这笔请求是我们的“售前咨询”还是“售后退款”也不知道是哪个产品线、哪个 prompt 版本产生的。想按团队、按应用、按模型去拆成本只能自己导出日志再做一遍清洗绕了一大圈。第三性能分析的维度不够。现成工具大多只展示模型的输入输出 token、首字延迟、总延迟但一个真实请求的耗时里有很大一部分发生在“模型调用之前”比如知识库检索、权限校验、多轮记忆组装。这些时间第三方工具看不到也就没法解释“为什么用户总觉得很慢但模型明明挺快”。所以我们的结论是监控系统可以借助一两款开源组件但核心的数据模型、采集链路、成本计算和报表平台必须自己控制。1.3 这次搭建的目标清单在动手之前我把这次监控系统的目标明确成下面几条后续所有设计都死磕这几项成本追踪能按自然日、按应用、按模型、按 Prompt 版本统计 token 消耗和预估费用误差控制在几个百分点以内。性能分析能区分“模型耗时”和“应用前置耗时”能看 TTFT、生成速度、错误率、重试率、超时分布。可追溯性每个异常请求都能通过一个 trace_id 回溯到业务订单、知识库检索片段、完整的请求消息和响应结果。稳定与轻量监控链路本身不能显著增加业务延迟不能把日志模块写挂了。数据保留线上明细保留至少 90 天支持随时拉取做专项分析。设定完目标就能看出来这不只是一个“埋点上报”的小项目它横跨数据采集、存储、计算、可视化和告警五块每一块都有不少细节。2. 监控系统的设计先把监控事件和指标定义清楚2.1 一次模型调用到底要记录哪些字段整个系统的基础是一条条“模型调用日志”。但这里的模型调用不是只记录“modelxxx用了多少 token”信息粒度得足够还原现场。我最后确定的记录字段大概分五组基础上下文request_id、trace_id、app_name、env、user_id、会话 ID。请求信息model、temperature、top_p、max_tokens、消息条数、prompt 的截断摘要。token 用量prompt_tokens、completion_tokens、total_tokens以及缓存命中的 token 数。性能指标首字延迟 ttft_ms、总耗时 total_ms、每秒生成 token 数、排队等待时间。结果状态finish_reason、HTTP 状态码、错误类型、是否重试、异常信息截断。这里有一个很关键的取舍要不要把完整消息体存下来。从调试角度看存完整消息最方便但从数据安全和存储成本看生产环境每天几万次调用完整消息可能一天就是几个 GB。我们最后做的是“截断加脱敏”请求和响应正文都默认只存前 200 个字符敏感字段在采集端直接打码。这样既能看到“这次让模型做了什么”又不至于把隐私数据整个铺进数据库里。2.2 成本追踪的底层原理成本追踪不复杂本质就是一个公式预估费用 prompt_token 数 × 输入单价 completion_token 数 × 输出单价难的是两点一是单价数据要能及时更新。OpenAI、国产开源模型这些接口的定价经常调整而且按百万 token 计价时换算成单 token 价格要小心小数点。我用一张 pricing 配置表兜底线上有一个后台可以随时改价格改了之后历史费用也能按旧价格重算。二是每条日志要记录模型名称的“事实版本”。不能等月底才从账单里反推而是每写一条日志就根据当时的配置表把费用算好填进去。以当时用的一个模型为例假设输入是 0.15 美元/百万 token输出是 0.60 美元/百万 token某次请求消耗 1200 输入 token 和 350 输出 token那么费用就是 1200 × 0.00000015 350 × 0.00000060 0.00039 美元约合人民币两厘多。单看一次没什么感觉日请求量五万次、平均每单费用翻两三倍就非常可观了。2.3 性能指标先看首字延迟再看生成速度大模型接口的性能指标和传统 HTTP 接口不一样最典型的是延迟拆成两段TTFTTime To First Token发出完整请求到收到第一个 token 的时间。这包含了排队、网络、服务端预填充和显存分配。用户感知里这个时间决定了“他开始打字回复”的快慢。生成阶段吞吐从第一个 token 到最后一个 token 的生成速率通常用 tokens/s 衡量。这决定了一段回复整体要多长时间才能展示完。我监控的时候会同时记录 ttft_ms 和 total_ms生成阶段时间大约等于 total_ms 减 ttft_ms再结合 completion_tokens 算出生成速度。举个例子某模型总耗时 4500msTTFT 是 700ms回复 300 个 token那生成阶段大概 3800ms平均每秒生成约 79 个 token。如果哪天生成速度掉到 40 tokens/s多半是服务端负载高了或者是响应被截断导致 token 数下降。除了延迟还有几个指标我一开始没注意后来发现非常重要finish_reason模型是正常生成完还是因为 max_tokens 截断、content_filter 触发、或者其他原因中断。截断在客服场景里经常出现它会直接导致回复不完整。错误率与重试率429、5xx、超时这些错误要分开统计。如果错误率 3%、重试率却到 30%说明客户端重试策略不够优雅。错误类型分布上下文长度超限、工具参数解析失败、provider 拒绝请求每一种有不同的处理方式。2.4 核心数据模型调用日志、定价表与链路关系整个系统最核心的是两张表调用日志表和定价表。调用日志表是一张大宽表业务团队想看什么基本都能从这里拆出来定价表维护模型单价成本计算模块依赖它。调用日志表的主体我用的是 PostgreSQLDDL 大致如下CREATE TABLE llm_call_log ( id BIGSERIAL PRIMARY KEY, request_id VARCHAR(64) NOT NULL, trace_id VARCHAR(64) NOT NULL, app_name VARCHAR(64) NOT NULL, env VARCHAR(16), model_name VARCHAR(128) NOT NULL, prompt_tokens INTEGER, completion_tokens INTEGER, total_tokens INTEGER, cached_prompt_tokens INTEGER DEFAULT 0, ttft_ms INTEGER, total_ms INTEGER, finish_reason VARCHAR(32), status_code INTEGER, error_type VARCHAR(64), request_truncated TEXT, response_truncated TEXT, estimated_cost_usd DECIMAL(12,8), inserted_at TIMESTAMP DEFAULT now(), UNIQUE (request_id) ); CREATE INDEX idx_llm_call_log_trace ON llm_call_log (trace_id); CREATE INDEX idx_llm_call_log_time ON llm_call_log (inserted_at);定价表很简单CREATE TABLE model_pricing ( model_name VARCHAR(128) PRIMARY KEY, input_price_per_million DECIMAL(12,4), output_price_per_million DECIMAL(12,4), effective_date DATE NOT NULL, update_time TIMESTAMP DEFAULT now() );之所以在价格上加了 effective_date是因为模型调价之后历史成本重算的工作量很大。日志表里已经写死了 estimated_cost_usd月底拉数直接用不用再用当时的日期去关联价格表。3. 从埋点到入库核心模块怎么落地3.1 采集层网关统一封装而不是让业务自己上报定义完字段之后最实际的问题是数据从哪来有两种常见做法一种是在业务代码里通过 SDK 埋点另一种是在统一的 LLM 网关层做日志采集。我们最后选择了从网关层采集。原因是我们的架构本来就是所有对模型 API 的请求都走一个内部网关网关统一处理鉴权、模型路由、重试和限流。在这里面加日志埋点业务代码完全无感知任何一个新接入的应用只要使用同一套网关就会自动获得监控能力。网关层采集的核心逻辑可以用一段伪代码来说明。假设网关是用 Python 写的 FastAPI 服务async def call_llm_with_monitoring(trace_id, request_payload): start time.perf_counter() request_id uuid.uuid4().hex try: response await llm_client.chat_completion( modelrequest_payload[model], messagesrequest_payload[messages], toolsrequest_payload.get(tools), **request_payload.get(params, {}) ) total_ms int((time.perf_counter() - start) * 1000) ttft_ms response.ttft_ms # 由底层 SDK 或流式解析得出 write_log( request_idrequest_id, trace_idtrace_id, model_namerequest_payload[model], prompt_tokensresponse.usage.prompt_tokens, completion_tokensresponse.usage.completion_tokens, total_tokensresponse.usage.total_tokens, ttft_msttft_ms, total_mstotal_ms, finish_reasonresponse.finish_reason, status_code200, request_truncatedtruncate(json.dumps(request_payload), 200), response_truncatedtruncate(response.text, 200), ) return response except Exception as exc: total_ms int((time.perf_counter() - start) * 1000) write_log( request_idrequest_id, trace_idtrace_id, model_namerequest_payload.get(model), status_codegetattr(exc, status_code, -1), error_typetype(exc).__name__, total_mstotal_ms, request_truncatedtruncate(json.dumps(request_payload), 200), ) raise注意几个细节使用 request_id 做唯一标识并在日志表上建了唯一索引天然支持幂等写入后续重试不会产生重复数据。所有写入操作都是异步的绝不阻塞主请求链路。成功和失败的请求都记录失败的请求虽然没有 usage但对分析问题同样重要。3.2 存储层异步写队列别把收集器做成性能瓶颈网关每分钟可能收到几百上千个事件如果每来一个请求就同步 INSERT 到数据库数据库先扛不住网关本身也会被拖慢。我们采用的是经典的内存队列加批量落库模式。网关进程里放一个 Queue采集器只负责把日志事件塞进队列一个后台 worker 线程负责攒批达到 100 条或 1 秒就批量 INSERT。这样写数据库的操作变成批量的性能大大提升监控系统对业务链路的干扰也降到了最低。简化后的 worker 逻辑如下def flush_worker(): buffer [] while True: event log_queue.get() if event is None: break buffer.append(event) if len(buffer) 100: batch_insert(buffer) buffer.clear()后面考虑到服务重启会丢内存队列里的日志我们把队列换成了 Redis Streamworker 单独部署进程挂了日志还在 Redis 里可以补写。但这是量级做到每天几十万请求之后才需要的前期一个带队列的进程完全够用。3.3 成本计算模块把单价管理做成配置而不是硬编码成本计算最忌讳的是把单价写在代码里上线后模型一调价就要发一次版本。我们把价格维护在 model_pricing 表里网关里的成本计算脚本只需要做查表和乘法。实现逻辑大致这样def compute_cost(model_name, prompt_tokens, completion_tokens): pricing get_pricing(model_name) if pricing is None: return None input_cost prompt_tokens * pricing.input_price_per_million / 1_000_000 output_cost completion_tokens * pricing.output_price_per_million / 1_000_000 return round(input_cost output_cost, 8)这里要特别提一个容易被忽略的问题有的模型在流式响应里不返回 usage需要在流式结束时自己把 token 数填进去有的兼容 OpenAI 格式的第三方接口usage 可能长期为零。我们的兜底方案是如果 usage 缺失就用本地 tokenizer 估算并在日志里打一个 warning 字段提示“该记录 token 为估算值”。宁可有一个明确标记的估算值也不要留一个空值让月底统计对不上。3.4 报表与分析用 SQL 拼出第一版成本看板数据落库之后初期不着急上复杂的数据可视化先把最常用的四个 SQL 查询做出来就够了按天、按模型统计 token 量和费用。按应用、按天统计调用量、平均 TTFT、P95 延迟。按 trace_id 拉出一条请求的完整链路明细。按错误类型分组看错误占比趋势。比如成本日报SELECT DATE(inserted_at) AS dt, model_name, COUNT(*) AS call_count, SUM(prompt_tokens) AS input_tokens, SUM(completion_tokens) AS output_tokens, SUM(estimated_cost_usd) AS cost_usd FROM llm_call_log WHERE inserted_at now() - INTERVAL 7 day GROUP BY 1, 2 ORDER BY cost_usd DESC;这个 SQL 基本就是我们内部大屏的第一版。后面数据量大了之后又加上了按月分区的清理策略但查询逻辑一直没怎么变。先让业务方能够看到数据比上一堆花哨图表重要得多。4. 性能分析与成本优化的真实案例4.1 线上“越来越慢”的排查模型明明没问题系统跑起来之后我们很快就接到了一个重要问题客服那边反馈“回答越来越慢”但打开模型厂商的后台服务状态一切正常。等我把监控数据拉出来发现一个有意思的现象模型 TTFT 中位数只有 500 毫秒总耗时中位数却高达 11 秒生成的单词数量也不算多为什么这么慢进一步看 trace 时间线发现真正的瓶颈根本不在模型调用阶段。一个请求从收到到最终返回经历了意图识别、权限查询、知识库向量检索、多轮摘要、工具参数校验这些加起来用了将近 8 秒模型只占了最后不到 3 秒。这个结论如果没有监控数据支撑很难说服业务方因为他们一直以为是“大模型太慢”。这个案例对监控系统的价值很大它证明了我们做两层延迟拆解是对的。前端只展示“总耗时”一定不够必须把模型的 ttft_ms、total_ms 和业务系统的“前置耗时”分开暴露才能继续优化该优化的环节。4.2 成本曲线上出现的三个“怪象”监控数据积累了一个月之后我在成本明细里发现了三个很典型的问题。第一个问题是 Prompt 滥用。某个销售助手应用把一段 5000 字的“客户手册”全文塞进了每个请求的 system prompt但业务上只有 30% 的场景会用到其中部分内容。按日请求量 1 万次计算每天光这部分冗余就多花了相当一笔钱。在监控里对比不同应用的“输入 token / 有效输出 token”比值一眼就能看出哪个应用在“高输入低输出”。第二个问题是工具调用失败引发的重复消耗。某个应用调用了工具来查询库存工具返回结果格式经常不符合代码预期模型不得不根据错误信息反复重新生成工具参数。外部观察是调用量翻倍但翻看 trace同一个问题在一条会话里最多出现了 7 次工具调用。这是纯浪费优化点是校验工具返回的 schema而不是加钱。第三个问题是缓存策略缺失。我们当时的接入方式没有用模型的 prompt 缓存知识库检索出的固定系统说明几百个 token 每次都重新计费。后来把这类“每用户相同的前缀”抽出来走缓存输入 token 降了不少。这几个问题没有监控系统之前其实也都存在只是因为看不到“具体花了多少”大家都觉得“模型贵是客观原因”。有了按 token 拆的数据每个团队都能对着自己的应用狠批一顿优化反而容易推进了。4.3 告警规则怎么定才不会把人打废告警是监控系统的放大器定得不好第一天全公司都会被打扰到。我最初从两个维度出发设计告警规则性能和成本。性能方面我的经验是先别追求“异常检测”直接设绝对阈值。比如P95 TTFT 连续 10 分钟超过 3000 毫秒触发“首字延迟过高”。最近 5 分钟错误率大于 5%触发“错误率异常”。人工客服请求P95 总耗时超过 15 秒触发“全链路超时”。成本方面告警最容易误报的是“今日成本环比昨天上涨 30%”。因为周末和周一本身就有自然波动直接用整体环比会误导。我最后加的规则是按应用维度看单应用成本环比前一工作日高出 50%并且调用量没有同步上涨才触发成本告警。这里的核心思路是告警是给处理问题的人看的一定要带上“可操作提示”比如“可能是 Prompt 变长请对比最近 24 小时平均输入 token”。纯“指标异常”的告警只会让人疲劳。4.4 一次优化改动的实证Prompt 压缩带来的成本降低有了监控系统优化动作的收益变得可测量了。我们做了一个典型的 Prompt 压缩实验把原来的系统提示词从 2000 字压到 900 字同时保留关键约束条件。用监控平台做了三组对比同样 500 条真实客服请求平均输入 token 从 2418 降到 1276节省接近 47%。因为输出 token 几乎没有变化回答质量在原评估集上也基本持平。最终这套优化叠加上缓存策略上线后日均调用成本下降了约三分之一而业务指标没有明显回退。如果没有监控压缩 Prompt 这件事很难说服团队放心上线因为你无法判断线上实际效果。这正是监控系统带来的一个隐性价值它让优化动作变得“敢做”。5. 实施过程中的常见坑和排查实录5.1 日志重复写入SDK 重试和回调混在一起第一个踩到的坑是日志重复。我们用 OpenAI 官方 SDK 时SDK 自带重试逻辑429 和 5xx 会自动重试。如果在每个请求的回调里都写一条日志重试一次就会多一条。更隐蔽的是流式接口可能自己触发内部重试但你从结果上根本看不到。解决方案是前面提到的 request_id 幂等。每次业务请求在入口处生成一个 request_id通过 header 传给网关重试时沿用同一个 request_id。日志表对 request_id 建唯一索引写入时用 ON CONFLICT 跳过已存在记录。这样就算代码里写重复了数据库也能兜底。5.2 token 计数不一致官方 usage 和本地 tokenizer 对不上我们还遇到过 token 对不上的情况。同一个中文 promptOpenAI 返回的 usage 和用本地 tokenizer比如 tiktoken 或 Hugging Face tokenizer计算出的结果不一样差异在几十个 token 以内不算大。但更严重的是某些第三方兼容接口压根不返回 usage全部是 0如果直接入库成本统计就会完全偏差。后来的处理原则是官方接口优先用 usage 里的值没有 usage 就用本地 tokenizer 估算并标记 estimated 标志估算值不参与对账单的精确核销但参与趋势分析。这个方法不算完美但至少保证看板上的数字不会突变。5.3 敏感数据入库了怎么办前面提到我们对消息正文做了截断和脱敏但这并不彻底。有一次我从 trace 里调试一个客服回答错误的问题点开详情才发现某个请求的 messages 里包含客户完整的手机号和订单号。虽然只有内部系统能看但这个隐患还是让我出了一身汗。之后我们做了两层加强第一层是采集端的字段过滤对手机号、身份证、银行账号等正则匹配后直接替换为掩码第二层是数据库层的查询权限控制只有专门的调试账号能看完整详情业务账号默认只能看到截断后的版本。如果你们在数据合规上要求更严格建议再加一层自动识别敏感字段的过滤服务在入库前就完成清洗。5.4 数据量上来之后分区、保留、清理策略监控系统刚上线时一天才几十万条日志不做清理也能撑很久。但随着调用量增长日志表随便就能堆到几千万行查询越来越慢存储成本也在涨。我们最后做了两件事。一是按天分区历史分区按月归档。PostgreSQL 开启分区表之后删除一个月的旧数据只是 drop 一个分区不用 DELETE 扫全表。每次查询也尽量限制时间范围命中分区后快很多。二是明细保留策略。线上明细保留 90 天超过 90 天自动转入冷存超过 180 天直接删除。成本报表只需要总量不依赖每条明细所以保留策略不会影响月度账单核对。5.5 高峰期批量写入压力以及监控自身的稳定性当每天的请求量超过几十万条时监控本身也开始成为需要关注的对象。一次促销活动业务量是平日的三倍Redis 里的日志积压了几十万条worker 消费不过来直接导致日志堆积。虽然业务没受影响但当天监控看板延迟了近两个小时。我们针对这个情况做了两个改进一是 worker 扩容平时 2 个实例大促前手动或者根据 Redis 积压量自动扩容到 6 个二是给日志采集增加了降级开关如果 Redis 队列长度超过阈值放弃采样只保留固定比例的事件保证看板能显示趋势。监控系统是“辅助”在极限压力下保证主业务稳定才是第一优先。6. 后续演进方向和一些个人体会这套系统从零到上线大概花了三周核心人力只有我和另一名后端同事。它没有解决大模型应用的所有问题但至少把“业务方问钱花在哪”和“线上变慢是谁的锅”这两个问题回答清楚了。后续我打算继续做三件事这里也给大家参考 一是把工具调用链路的成本单独拆出来。现在工具调用次数多很多成本藏在“一次会话里反复调工具”上需要在 trace 层面按步骤聚合。 二是接入更多非 OpenAI 兼容的私有化模型。如果走本地部署比如通过 ONNX 等推理引擎承载模型除了 token 统计还要监控 GPU 利用率、排队时间这两类指标的数据来源和存储模型跟线上 API 完全不同。 三是把成本治理从“事后看”变成“事前管”给每个应用设预算按比例做月度 quota预算快耗尽时自动降级模型或者限制并发。这一步做到位才算是真正从监控走向了治理。实际操作中我最大的感受是监控系统这件事真的没有想象中那么高深但也别指望套用一个开源工具就能一劳永逸。关键在于你愿不愿意把“一次模型调用”拆得足够细把“钱”和“性能”的账算得足够清楚。数据到手了很多以前靠猜的问题就变成了表格和曲线。这套能力值得每一个正经做 LLM 应用的团队认真花时间建起来。
返回列表