每次排查LLM调用问题最让我头疼的往往不是模型本身而是我拿不到一份完整的调用记录。某个用户反馈昨天下午的回答突然变得很啰嗦我需要知道当时传了哪些messages、temperature设置成了多少、用了哪个模型版本、返回的usage是多少结果发现日志里只记了一句提示词摘要。没有标准就不会有人告诉你哪些字段该记也不会有统一的格式等着你。后来我干脆自己设计了一个LLM调用记录方案。它不算什么新发明但让我第一次能在问题发生之后用可验证的数据去还原当时发生了什么。我见过不少团队把LLM接入应用之后第一件事是调prompt、换模型、追指标很少有人认真想过每一次调用到底该留下什么痕迹。等到线上出问题才意识到记录比模型本身更决定你能不能快速定位问题。这个领域发展太快标准还没跟上。于是“该记录什么”就成了一道由每个团队自己回答的题。这篇文章想分享的是我在解决这道题时形成的方案和判断。1. 为什么LLM调用记录没有标准会成为工程化路上的第一道坎1.1 调用出了问题你拿什么来复盘传统的API调用日志里记下method、path、status code、response time、trace id基本就够了。因为传统API的输入输出结构简单问题和数据之间的因果链也比较清楚。LLM调用不一样同样的输入在不同的模型、不同的temperature、不同的随机种子下可能产生完全不同的输出。真要复盘至少得知道当时发给模型的完整消息序列、采样参数、模型版本、以及用量信息。我经历过一个真实场景一个客服机器人偶尔会输出一段与问题完全无关的话。用户怀疑模型被“污染”了而我能看到的只有数据库里存的用户问题和一条“调用失败”或“返回异常”的日志。没有当时完整的messages我就无法判断是不是历史对话里藏着误导信息没有temperature就无法判断是不是随机性过高没有模型版本就无法确认是不是模型厂商偷偷更新了行为。最后只能让用户复现而让用户复现一条概率性问题几乎等于大海捞针。这种困扰的本质是记录信息不足而不是模型不行。传统API的“标准日志思维”放到LLM调用上会漏掉太多关键变量。1.2 没有标准连“记录好了”都很难验证更麻烦的是现在没有一套像HTTP状态码那样被广泛接受的LLM调用日志规范。OpenTelemetry社区已经在推进生成式AI相关的语义约定很多厂商也有自己的监控协议但它们覆盖的字段、事件模型、上下文关联方式都不完全一样。你按A平台的格式记录换到B平台做评估可能又要改一遍你自建了一套字段后来想接入第三方可视化工具还要写转换层。没有标准带来的不是“选哪个规范”的问题而是“记录齐不齐”都很难判断。因为你没有一个可靠的参照系。比如“请求上下文”这个词在有的方案里指messages在有的方案里指system prompt user message在有的方案里还包含tools、functions、assistant历史。不同定义会导致你记录的范围完全不同。缺少标准时我们只能自己定义一套语义并尽量让它覆盖常见场景。在我眼里这是LLM应用工程化的一道坎如果没有一个稳定的记录格式后面所有基于记录的调试、评估、成本分析、审计、复现都会变得摇摆不定。我选择自己构建一套记录核心就是这个原因。它不一定完美但至少让团队内部再也不用争论“该记什么”。2. LLM调用记录该记什么一套面向复现、审计和评估的字段集2.1 请求侧要留痕关键不是prompt而是完整上下文很多人记录LLM调用时只把user的输入和assistant的输出存下来。这远远不够。真正决定一次生成结果的是发送给模型的完整消息数组包括system prompt、历史对话、工具结果、few-shot示例等。只有把messages完整保留下来后续才能精确复现那次调用也才能判断是不是历史消息污染了模型。我建议把请求侧至少记录这几类信息模型标识model名称如果有版本或部署ID也要记录。完整messages消息角色、内容、名称、工具调用相关字段。生成参数temperature、top_p、max_tokens、stop、presence_penalty、frequency_penalty等。扩展能力如果使用了tools、functions、response_format也要一并记录。调用的业务上下文比如会话ID、用户ID、应用版本这能帮你把日志对回真实场景。需要注意请求侧记录得越全复现能力越强但存储成本也越高。实际落地时我会先把完整messages写入一个独立的存储不对所有人的日志查询开放。这些内容可能包含敏感信息所以后续必须做脱敏和权限控制。2.2 响应侧不只要记content还要记收尾原因和用量响应侧最容易被忽略的是finish_reason和usage。finish_reason告诉你模型是因为正常结束、达到了max_tokens、触发了停止词还是因为内容过滤而中断。不同原因意味着问题完全不同的处理方向如果是length你大概率要调整max_tokens或提示词长度如果是content_filter可能需要重新考虑输入内容合规性。usage主要用于成本监控和Token用量分析包括prompt_tokens、completion_tokens、total_tokens。如果你接入了多个模型还要记录计费单价和估算成本方便后续做成本归因。很多团队事后发现模型费用暴涨就是因为日志里没有记录usage导致成本分析只能靠猜。响应内容本身也需要记录。但要注意生成内容可能很长记录时应权衡。我更建议先完整保存后续再通过数据生命周期策略做归档或压缩。如果因为存储压力必须省至少要保存精简后的内容摘要并保留对原始存储位置或trace ID的引用。2.3 环境与业务元信息才是跨系统追踪的线索除了请求和响应你还得记录这次调用发生时的环境信息、时序信息和业务上下文。这些字段单独看没什么用但它们是把一次LLM调用放回整个业务流程的关键线索。我常用的元信息字段包括timestamp发起调用的时间精确到毫秒或微秒。request_id一次调用生成的唯一ID。trace_id / parent_span_id如果接入了链路追踪要保留关联ID。latency_ms从发起到返回的总耗时包括重试时间。status调用成功、失败、超时、被拦截等状态。error_info异常类型、错误消息、重试次数。app_version / environment代码版本和部署环境。session_id / user_id业务侧的定位信息。别小看这些字段。没有timestamp你无法做时间维度的趋势分析没有trace_id你无法把日志和APM系统串联起来没有status和error_info你无法快速过滤出失败样本。它们构成了记录系统的骨架而请求和响应只是上面的血肉。3. 从零构建一个最小可用的LLM调用记录器3.1 先用装饰器把调用接住一个低侵入的起始方案构建记录器的第一步不是设计一个大而全的数据平台而是先让每一次调用都能被“接住”。我比较推荐用装饰器或中间件的方式把LLM客户端调用包起来这样业务代码不用到处塞日志。下面是Python里的一个示意结构用来理解整体思路import json import time import uuid from functools import wraps def record_llm_call(record_pathllm_calls.jsonl): def decorator(func): wraps(func) def wrapper(*args, **kwargs): request_id str(uuid.uuid4()) started time.time() response None error_info None status ok try: response func(*args, **kwargs) return response except Exception as exc: status error error_info repr(exc) raise finally: elapsed_ms round((time.time() - started) * 1000, 2) record { request_id: request_id, timestamp: started, elapsed_ms: elapsed_ms, model: kwargs.get(model), messages: kwargs.get(messages), parameters: { temperature: kwargs.get(temperature), max_tokens: kwargs.get(max_tokens), top_p: kwargs.get(top_p), }, response: response, status: status, error: error_info, } with open(record_path, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n) return wrapper return decorator这个示例最大的好处是侵入小只需要在原有函数上增加一个装饰器。它适合个人项目或小团队快速验证。但注意如果原函数内部有重试、流式输出、并发调用情况会更复杂这个装饰器只能作为起点。3.2 日志格式JSONL比纯文本更适合程序和人类记录格式我建议优先使用JSONLJSON Lines也就是每一行一条JSON记录。相比纯文本日志JSONL有几个明显优势每行自包含可以逐行读取方便grep也方便导入数据库。相比单个巨型JSON数组JSONL更适合追加写入不需要频繁修改文件结构。示例一行记录{request_id: abc-123, timestamp: 1698888888.123, model: gpt-4o, messages: [{role: system, content: ...}], temperature: 0.2, status: ok}实际写入时要处理好序列化问题。messages里的内容可能包含不可见字符、特殊符号、超大字段甚至二进制内容。建议统一做编码转换并在写入前确认JSON能正常序列化。如果响应对象里包含非JSON类型需要先转换成dict或字符串。3.3 最小验证顺序跑通一次检查字段再谈批量很多人的习惯是写好代码后直接跑一个几十条的批量任务。我建议反过来先只跑一次然后立刻打开日志文件检查记录是否完整。验证顺序可以按下面这个链路走先看有没有记录产生。如果没有先检查装饰器是否真正作用到了执行函数上。再看字段是否完整。model、messages、parameters、response是否都写进去了。再看JSON是否合法。用jq或Python的json.loads逐行解析。再看敏感信息是否脱敏。如果messages里包含手机号、身份证号等说明还需要加脱敏步骤。再看异常场景。故意传一个不该传的参数确认错误分支也能写出日志。只有这几步都通过了再去做批量调用。否则一旦批量跑完格式错误、字段缺失、敏感数据泄露这些问题会成倍放大。注意不要一上来就把批量数和并发数拉满先用一条样例确认输入、输出和日志都正常。4. 当记录从单次变成批量需要补上的工程化拼图4.1 并发与重试会让记录重复或丢失单次调用成功只说明流程没有断。一旦进入并发和重试场景记录系统就会暴露问题。比如使用异步客户端多个请求同时完成时如果都用同一个文件句柄写入可能产生交错写入或文件锁冲突如果调用过程中发生了重试同一个业务请求可能产生多条记录但你很难把它们关联起来。我一般会做这样几件事为同一个业务请求生成一个稳定的request_id并把每次重试也记录为同一条请求下的attempt。写入时使用追加模式并尽量让写日志本身不阻塞主流程。给记录加上唯一约束或去重逻辑避免消费端重复处理。在并发场景下优先考虑写入消息队列或使用数据库插入而不是直接写本地文件。这些点看起来小但会直接决定记录是否可信。如果记录重复你统计的调用次数和成本就会出现偏差如果记录丢失你排查问题时就会再次面临“数据缺失”的窘境。4.2 脱敏和权限记录越全风险越大记录完整的messages意味着你保存了大量用户输入和模型输出。这些内容可能有个人隐私、业务机密、内部代码。如果日志文件被拖走问题就不再是“看不到调用记录”而是“数据泄露”。我建议在写日志之前就做脱敏而不是等存储之后再做。脱敏的方式可以很直接对手机号、邮箱、身份证号等字段做正则替换。对包含敏感字段的message只保留首尾字符。对用户输入中的token、密钥、内部URL做掩码。记录存储层需要设置访问权限不能所有人都能读。如果业务合规要求更高还可以考虑只记录消息的哈希值或文本指纹而不保留原文。但代价是后续无法用记录来复现完整对话。这需要团队在“可复现”和“数据安全”之间做取舍。我的经验是开发环境可以多记生产环境必须脱敏并且尽量把日志和原始数据分层隔离。4.3 存储与查询从JSONL到结构化数据库当调用量上来以后JSONL文件会越来越大查询效率会急剧下降。比如你想查某个用户所有调用记录或统计某天平均延迟用grep扫大文件会非常痛苦。这时候需要考虑把记录导入结构化存储。小规模项目可以用SQLite既不需要额外服务又能用SQL查询。操作方式是定期把JSONL导入临时表或者直接在写入时插入数据库。中等规模项目可以用PostgreSQL、ClickHouse或Elasticsearch具体看你的查询模式和成本预算。我的建议是保留两层原始事件流保留在日志或对象存储中用来做审计和重建加工后的结构化数据进入数据库用来做查询和监控。两层之间通过request_id关联。这样即使数据库误删也能从原始记录恢复。如果你接入了OpenTelemetry也可以把LLM调用作为特定span的属性记录与现有链路追踪统一管理。但要提前确认当前使用的SDK或平台是否支持生成式AI的语义约定避免记录格式不兼容。5. 把LLM调用记录变成一个可复用流程我的四步框架5.1 记录什么该进日志什么该进数据库面对越来越多的字段我逐渐形成了一个简单的分流原则时序事件进日志需要联查的数据进数据库。日志记录完整messages、原始响应、错误信息、请求上下文。这些内容需要保留原始性用于复现和审计。数据库记录request_id、timestamp、model、status、latency、usage、估算成本、业务维度字段。这些内容结构规整适合统计分析。对消息内容可以只在数据库里存摘要或脱敏版本原始内容放对象存储并在日志中引用位置。这个原则让架构保持清晰也避免把所有数据都塞进数据库导致成本失控。5.2 重放如何用一份记录让问题可复现有了记录接下来就是让它发挥真正价值。遇到线上问题时我会从记录里取出当时那次调用的完整messages和参数用同一模型版本重新发起一次调用。如果问题能复现说明是输入或参数引发的如果不能复现就要考虑随机性、模型更新或外部依赖差异。为了支持重放记录里最好包含“模型请求的原始契约”而不只是表面文字。例如tools的定义、response_format、stop参数都会影响模型行为。少了这些重放就可能失真。重放时还可以做对比同一条消息分别用temperature0和原始参数跑一遍用来判断问题是否随机性导致。如果一个概率性错误只在高temperature下出现你就知道需要调低随机度或增加校验逻辑。5.3 评估让记录成为持续改进的输入记录不只是给“出问题”时用的。我会定期从记录里抽一批样本做多维评估成功率有多少调用status不是ok。失败原因分布是超时、限流、上下文超长还是内容过滤。延迟变化不同时间段、不同模型之间的延迟对比。成本趋势按模型、业务功能、用户维度统计Token消耗。输出质量对部分输出做人工评分或模型评分。这些评估都依赖同一份记录。如果没有标准化的字段和格式这些统计很难自动化。我构建这套记录方案后最大的收益不是“多了日志”而是可以每周自动出一份LLM调用健康报告把原来靠感觉判断的问题变成可量化的数据。5.4 复盘没有标准不可怕可怕的是没有闭环最后一步是复盘。每次线上事故或质量问题都要回到记录里去看是哪一层缺失导致了排查困难是记录字段不够还是记录格式不合理或者是存储链路断了把这些问题反馈回记录方案形成“记录-发现-调整-在记录”的闭环。标准缺失是现实但并不意味着我们只能被动等待。一套自制的记录方案即使原始一点、简单一点只要字段语义明确、覆盖完整、可扩展就能在很长一段时间内支撑团队开发和运维。将来如果社区标准成熟再写一个转换层把自建格式映射过去就可以了总比现在什么都不记要强得多。我自己经历过“没有记录”时的无力感也体验过“记录齐全”后快速定位问题的顺畅感。两者的差距不只是一条日志而是整个系统在面对不确定性时的可控程度。如果你也正在做LLM应用我建议从今天开始先给调用加上一份结构化的记录哪怕只记录最基础的几个字段。等真正遇到问题的时候你就会理解这可能是你这个项目里最值得的一笔工程投入。