你有没有经历过这种 Debug 现场:线上 LLM 应用回答质量突然下降,你想定位是 RAG 检索没召回对应文档,还是模型生成被 Prompt 里某个指令带偏了,结果翻遍服务日志,只看到一行 print("done") 和几条孤零零的 INFO。那一刻你会意识到,在 LLM 应用里,日志从来不是"有没有"的问题,而是"配置起来痛不痛、查起来快不快"的问题。
我刚转做 LLM 应用开发时,最先崩溃的就是日志配置。Python 标准库 logging 的 Handler、Formatter、Filter、Logger、Level,一套流程下来要写二十来行模板代码,而且每个模块还得各搞一套。后来用了 Loguru,一行 logger.add() 就把格式、级别、文件轮转、异步写入全搞定。项目里跑了半年,从 Agent 链路追踪到微调脚本监控都在用它。这篇博文就把我的实际用法和踩过的坑一次讲清楚,适合正在做 LLM 应用、RAG 服务、Agent 编排或者模型微调脚本的人参考。
1. LLM项目的日志为什么比传统后端更让人头疼
1.1 传统logging三件套在LLM场景的尴尬
先说实话:标准库 logging 本身设计没有问题,但它在 LLM 场景下用起来就是很不顺手。传统后端日志的核心诉求是"记录异常、状态码、耗时、报错栈",每一条日志短小精悍,Handler、Formatter、Filter 各司其职不会有太大问题。
但 LLM 项目的日志形态完全变了。一条 Prompt 可能几百上千 token,一次模型返回可能是一大段生成文本,一次 Agent 调用链还要串起工具调用、检索结果、多轮模型交互。用标准库写,要么在 Formatter 里堆各种字段拼接,要么在每个调用点手写 logger.info("prompt: %s, response: %s", prompt, response),写两天你就烦了。更头疼的是,LLM 项目里经常要多进程处理请求、异步并发调模型,标准库的 QueueHandler、QueueListener 配置起来又是一大坨模板代码。
Loguru 的做法是取消这些概念,全部收敛成一个全局 logger,对外的 API 只有一个 add()。你不需要在代码里传 logger 对象,也不需要记得在文件头部实例化某个 Handler。它默认输出的格式已经包含了时间、级别、文件名、行号,直接 logger.info("hello") 就能用,这非常适合 LLM 项目里那种"想法变代码很快、日志顺手记录"的节奏。
1.2 LLM日志必须回答的四个问题
LLM 应用里的日志,本质上要回答四个问题,少了任何一个都会让人抓狂:
- 调了谁:用了哪个模型、哪个 Prompt 模板、哪个 Embedding 模型、温度参数是多少。
- 花多少:Prompt token、Completion token、单次调用耗时、估算费用。
- 结果如何:模型返回了什么、检索命中了哪些文档、Agent 做了哪些工具调用。
- 链路怎么串:一个用户请求触发了哪些子任务,每个子任务的日志如何串成一条完整的 trace。
标准库 logging 做第一点和第二点很勉强,做第四点基本要自己造轮子。Loguru 的 bind()、contextualize() 以及 extra 字段机制,恰恰就是为这类结构化追踪设计的。看完后面几节的例子,你会对"日志配置无痛"有更直接的感觉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从logging到Loguru:一行add()替代Handler、Formatter、Filter
2.1 Loguru的setup直觉:add()一个函数搞定所有
不管你是刚接触 Loguru 还是已经被标准库折磨过,核心只需要记住下面这个函数:
python复制from loguru import logger
logger.add(
"app.log", # 写到文件
level="INFO", # 最低级别
format="{time} | {level: <7} | {name}:{function}:{line} | {message}",
rotation="10 MB", # 文件超过10MB自动切割
retention="7 days", # 只保留最近7天
compression="zip", # 切割后压缩
enqueue=True, # 异步写入
encoding="utf-8",
)
先别急着背参数,我来逐个说明为什么这些在 LLM 项目里都有用。
rotation:LLM 应用单条日志就可能上百 KB,一个高频服务一天的日志量轻松超过 1GB。没有自动轮转,日志文件必然撑爆磁盘。写成"10 MB"或"00:00"都可以,前者按体积切,后者按时间切。retention:LLM 日志里大量是 Prompt/Response 内容,占用空间大,但往往只需要留最近几天的排障。设置 7 天或 30 天,省心很多。compression:切割后的日志压缩成 zip,能省出不少磁盘,尤其是日志里塞了长文本的情况下。enqueue=True:让日志写入走内部异步队列。这个对 LLM 服务很关键——如果你在同步 API 里阻塞写大段文本到磁盘,用户请求的响应时间会被明显拉长。异步写入可以做到日志 I/O 不阻塞主业务。
如果你不指定 sink,它默认输出到标准错误(stderr),开发环境下打开终端就能看到带颜色的日志。开发、测试、生产环境习惯不同,我通常用多个 add() 同时配置:一个输出到终端便于调试,一个写入文件做持久化,还能再加一个 JSON sink 给日志采集平台。
2.2 rotation与enqueue:两个秒懂的保命参数
我在生产环境遇到过最典型的"日志事故"是这样的:某个 Agent 服务没配任何轮转,跑了两周后日志文件涨到 30GB,把磁盘占满,导致容器不断重启。没配 enqueue 时,日志量大起来还会拖慢接口响应。
所以我的建议是:add() 里的 rotation 和 enqueue 不是可选项,而是必选项。rotation 等于给你的日志文件装上"自动垃圾桶",enqueue=True 则把日志 I/O 和业务线程解耦。这两个参数加不加,体验完全是两回事。
2.3 filter与level:不是所有日志都值得落盘
filter 参数接受一个函数,能按记录内容动态决定这条日志要不要处理。这在 LLM 项目里非常实用。
比如我只想把 LLM 调用相关的日志写到 llm.log,其他框架日志过滤掉:
python复制def llm_only(record):
return record["extra"].get("channel") == "llm"
logger.add("llm.log", filter=llm_only, level="DEBUG")
再比如我想在输出文件里过滤掉包含敏感内容的日志,也可以在 filter 里做一次脱敏再返回 True。要注意的一点是:filter 的优先级高于 level,两者同时配置时,先走 filter 再判断 level。
级别方面,我总结了一套比较顺手的口径:DEBUG 记录完整 Prompt/Response 和工具返回的原文;INFO 记录调用摘要(模型名、token、耗时、成本);WARNING 记录重试、降级、token 超限、上下文截断;ERROR 记录模型调用失败和链路异常。这套分级在 LLM 项目里非常好用,既能保证排查有依据,又不会让日志量大到离谱。
3. 把LLM调用变成一条结构化记录:装饰器、Token与成本侧写
3.1 用装饰器给所有LLM调用统一装上"记录仪"
如果你习惯在每个 LLM 调用点手写 logger.info(),很快会发现两个问题:要么漏记,要么格式不统一。最省事的做法是写一个装饰器,把所有模型调用函数包一层,让日志记录变成自动行为。
下面这个装饰器是我项目里一直在用的基础版:
python复制import time
import functools
from loguru import logger
def log_llm_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
logger.info(
"LLM调用成功 | 函数={} | 耗时={:.3f}s | 参数={}",
func.__name__, elapsed, kwargs
)
return result
except Exception as e:
elapsed = time.perf_counter() - start
logger.exception(
"LLM调用失败 | 函数={} | 耗时={:.3f}s | 错误={}",
func.__name__, elapsed, e
)
raise
return wrapper
这段代码的核心思路是:观测逻辑和业务逻辑解耦。你的业务代码只需要关注"调模型、拿结果",日志自动帮你记下谁被调了、耗了多少、成没成功。logger.exception 会在异常时连 Traceback 一起打出来,这对 LLM 调用失败时的定位非常有帮助。
如果你用的是 OpenAI SDK,还能更进一步,直接读取 response.usage 拿到 token 明细:
python复制@log_llm_call
def call_gpt(client, messages, model="gpt-4o-mini", **kwargs):
resp = client.chat.completions.create(
model=model,
messages=messages,
**kwargs
)
usage = getattr(resp, "usage", None)
if usage:
logger.info(
"model={} | prompt_tokens={} | completion_tokens={} | total_tokens={}",
resp.model, usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
)
return resp.choices[0].message.content
这个写法有个好处:日志里出现的 token 数字是 SDK 官方返回的,"实测"比任何估算都准。需要提醒的是,不是所有模型 SDK 都带 usage,接一些开源模型本地部署时,需要自己数 token 或直接不统计,别因为拿不到 usage 就直接抛异常。
3.2 记录token与预估成本:让每一次调用都看得见花费
LLM 项目里,token 就是钱。成本记录不能等月底看账单才发现超支,最好每次调用就估算一次。下面是一个简单的成本估算模板,模型单价以官方最新报价为准,我这里只是示例:
python复制MODEL_PRICES = {
"gpt-4o": {"input": 2.50, "output": 10.00}, # 美元/百万token
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"deepseek-chat": {"input": 0.27, "output": 1.10},
}
def estimate_cost(model, prompt_tokens, completion_tokens):
prices = MODEL_PRICES.get(model)
if not prices:
return 0.0
cost = (prompt_tokens / 1_000_000) * prices["input"]
cost += (completion_tokens / 1_000_000) * prices["output"]
return cost
在装饰器里加上成本估算:
python复制logger.info(
"LLM调用 | model={} | prompt_tokens={} | completion_tokens={} | 耗时={:.3f}s | 预估成本=${:.4f}",
resp.model, usage.prompt_tokens, usage.completion_tokens, elapsed, cost
)
这样日志里每天都能看到每次调用的费用。我后来还做过一个简单脚本,把日志里的 cost 字段全部加起来,就是单日大致的模型花费。虽然没有云厂商账单那么精确,但足够做成本预算和异常消耗预警。
3.3 长文本截断:避免Prompt和Response把日志撑爆
LLM 日志最大的"体积杀手"就是完整 Prompt 和完整 Response。尤其做 RAG 时,一个 Prompt 里可能塞了几千字的检索上下文,如果全部落盘,单个请求的日志就奔着几十 KB 去了。我建议写一个截断函数:
python复制MAX_LOG_LEN = 500
def truncate(text, max_len=MAX_LOG_LEN):
text = str(text)
return text if len(text) <= max_len else text[:max_len] + "……[截断]"
在记录完整内容时,用 truncate() 包一层:
python复制logger.info("prompt摘要={}", truncate(prompt))
logger.info("response摘要={}", truncate(response))
这样既保留了可读性,也不会因为一个请求产生超大体积日志。如果你确实需要保存完整 Prompt 和 Response,不要写进普通文本日志,建议单独用一个 JSON 文件或数据库存储,按 request_id 关联起来。
4. Agent与异步场景:bind()和contextualize()把日志串成链路
4.1 为什么普通日志在Agent场景不够用
Agent 类应用比单次 LLM 调用更复杂:用户发来一个问题,Agent 可能要经历"规划→调用搜索工具→读取网页→再调模型→总结回答"这样多个步骤。每一步都有日志,但问题来了——如果不知道哪条日志属于哪个用户请求,你看到的就是一堆散落的碎片,根本拼不出完整过程。
传统做法是给每行日志手动拼 request_id,比如:
python复制logger.info("[{}] 开始调用搜索工具", request_id)
这种写法丑陋且容易漏。Loguru 的解决方案是 bind() 和 contextualize(),把 request_id 做成日志上下文的一部分,不需要在每个调用点手动拼接。
4.2 bind():为某个请求定制专属logger
bind() 能生成一个带 extra 字段的 logger 副本,之后这条链路上所有通过它打的日志都会自动带上你绑定的字段:
python复制request_logger = logger.bind(request_id="req_abc123", session_id="sess_001")
request_logger.info("开始处理用户问题")
request_logger.info("调用检索模块")
在格式里加上 {extra[request_id]} 后,日志会自动变成:
text复制2025-03-20 10:00:00 | INFO | 开始处理用户问题 | extra: {'request_id': 'req_abc123', 'session_id': 'sess_001'}
这样按 request_id 一 grep,一个请求的完整生命周期就出来了。不过我要提醒一个细节:logger.bind() 返回的是新 logger,不是修改原 logger。如果你在某个函数里 logger = logger.bind(...),这个绑定只对当前作用域内的 logger 对象生效,千万别以为自己全局 logger 变了。
4.3 contextualize()与FastAPI中间件:让request_id贯穿全链路
对于 Web 服务,我更推荐 contextualize() + FastAPI 中间件的组合。它在请求处理完之前,让所有使用全局 logger 的日志自动带上当前上下文的字段:
python复制import uuid
from fastapi import FastAPI, Request
from loguru import logger
app = FastAPI()
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex)
with logger.contextualize(request_id=request_id):
response = await call_next(request)
return response
这样做的妙处在于:你的业务代码完全不需要感知 request_id,直接用全局 logger.info("xxx"),日志里就会自动带上当前请求的 ID。我用这个方案接 Agent 服务后,排障效率明显提升。
这里有一个值得注意的坑:contextualize() 用的是 ContextVar,在 asyncio 的 create_task 子任务里可以继承,但在线程池(如 asyncio.to_thread、ThreadPoolExecutor)里不会自动传递。如果 Agent 走了线程池执行工具调用,子线程里的日志可能丢掉 request_id。解决办法是用 contextvars.copy_context() 把上下文显式复制给子线程。这个细节不处理,日志追踪会出现莫名其妙的断链。
Agent 场景里我还喜欢给日志补一个 stage 字段:
python复制logger.bind(stage="retrieval").info("命中chunk个数={}", len(chunks))
logger.bind(stage="llm_generation").info("生成完成,token={}", total_tokens)
有了 stage 字段,后续在日志平台里直接按阶段聚合,能看出每一环的耗时占比,优化链路时靠的就是这个。
5. 把openai、httpx、faiss的日志统一收编:标准logging桥接实战
5.1 LLM项目的第三方日志为什么混乱
做 LLM 开发,你不可能只用 Loguru,SDK 和底层库大概率还是用的标准库 logging。于是会出现这样的局面:你的应用日志在 Loguru 里打印得干干净净,但某个底层库的 warning、error 却以标准库的格式漏到控制台,格式和你的日志完全不一致;或者你想看 openai SDK 的 DEBUG 日志,但标准库默认不输出,怎么调都没反应。
在 LLM 项目里,这个"日志割裂感"不是小问题。很多关键排障信息恰恰藏在第三方库的日志里——比如 httpx 的 DEBUG 日志会记录完整的 HTTP 请求和响应信息,对你复现线上问题很有用。统一收编这些日志,是生产环境绕不开的一步。
5.2 InterceptHandler:标准库日志全部改走Loguru
官方文档提供过一个 InterceptHandler,原理是把标准库的日志记录器拦截下来,重新送到 Loguru 的管道里统一处理。我项目里的简化版本长这样:
python复制import logging
from loguru import logger
class InterceptHandler(logging.Handler):
def emit(self, record):
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno // 10 * 10
frame, depth = logging.currentframe(), 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
# 将所有标准库 logger 的 handler 替换成 InterceptHandler
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
执行完这段后,openai、httpx、faiss、chromadb 这些库的日志,都会以 Loguru 的格式输出,统一走你配置的 rotation 和 filter。个人感受是,这个改造带来的体感提升非常明显——异常栈格式统一了,日志时间格式统一了,文件轮转也统一了。
如果你不想全局拦截,只想把某一个库的日志接进来,可以用更轻量的方式:
python复制logger.add("http_debug.log", filter=lambda record: record["extra"].get("name") == "httpx", level="DEBUG")
logging.getLogger("httpx").addHandler(InterceptHandler())
这种做法适合只想排障时临时开启的场景。
5.3 httpx的DEBUG日志里藏着API Key,这个坑必须注意
这里必须着重提醒一件事:httpx 的 DEBUG 日志会打印完整的请求头,而 openai 等 SDK 通常用 Authorization: Bearer sk-*** 作为 API Key。如果直接把 httpx 的 DEBUG 全量打开并写入日志文件,等于把 Key 明文落到磁盘上,存在严重的安全风险。
我踩过一次之后就定了两条规矩。第一,生产环境默认不开启 httpx 的 DEBUG;只有在本地调试或临时排查时才打开,排完马上关。第二,所有写入文件或上报日志平台的日志,都要过一层脱敏过滤。下面这段是我常用的脱敏函数:
python复制import re
SENSITIVE_PATTERNS = [
(r"(sk-[A-Za-z0-9]{4})[A-Za-z0-9_-]+", r"\1***"), # OpenAI Key
(r"(Bearer\s+)[A-Za-z0-9._-]+", r"\1***"), # Authorization头
]
def mask_secrets(record):
message = record.get("message")
if isinstance(message, str):
for pattern, repl in SENSITIVE_PATTERNS:
message = re.sub(pattern, repl, message)
record["message"] = message
return True
logger.add("app.json", filter=mask_secrets, level="INFO")
不要觉得这是小题大做,LLM 项目的日志一旦汇聚到 ELK 或 Loki 这类平台,能搜到日志的人可能远比你想的多。把敏感信息在落盘前处理掉,是最稳妥的做法。
6. 训练与微调脚本的日志规范:别再用print记录loss和精度了
6.1 训练日志的诉求和推理服务完全不同
很多写微调脚本的人有个习惯:用 print 把每个 step 的 loss 打到控制台,跑完再看 TensorBoard。开发阶段没问题,但一上多机多卡,print 就完全不够用了——日志丢失、文件混乱、无法追溯每次实验的参数差异。
微调场景的日志诉求和推理服务不一样:你更关心长时间训练过程中的指标变化、异常阶段,以及超参配置。这时候日志系统要能自动按时间切文件、异步写入避免阻塞训练、同时把关键指标结构化记录,方便后续绘图和分析。
6.2 一个可复用的训练日志模板
我用的一个比较顺手的模板,把训练配置和指标一起写入日志:
python复制from loguru import logger
# 训练开始时自动按时间切片文件名
logger.add("train-{time:YYYY-MM-DD}.log", rotation="100 MB", retention="14 days", enqueue=True)
def log_train_config(config):
logger.info(
"训练配置 | model={} | precision={} | batch_size={} | lr={} | epochs={} | dataset={}",
config["model"], config["precision"], config["batch_size"], config["lr"],
config["epochs"], config["dataset"]
)
训练循环里,我一般每固定步数记录一次关键指标:
python复制logger.info(
"step={} | loss={:.4f} | lr={:.2e} | tokens_seen={}",
step, loss, current_lr, tokens_seen
)
这里特别说一下精度策略。微调时用 FP16、BF16 还是 FP32,会直接影响 loss 曲线和最终效果。日志里不记精度策略,之后回来复盘实验会发现"当时的 loss 和现在的 loss 对不上",根本原因就是精度设置不同。把 model、precision、batch_size、lr 这些元信息打在第一行日志里,是让实验结果可复现的基本功。
另一个实用点是 enqueue=True 在训练脚本里的价值,训练循环频繁写日志,异步写入能减少日志 I/O 对训练步进的影响。虽然训练瓶颈基本在 GPU 上,但日志 I/O 攒多了,CPU 侧的开销依然会拖慢数据预处理。
7. 生产环境跑了一阵之后,我总结的几个Loguru避坑点
7.1 多进程写同一个文件,轮转会出问题
Loguru 的 enqueue=True 是进程内的异步队列,不是跨进程分布式队列。如果你用 Gunicorn 起了多个 worker,每个进程都往同一个日志文件里写,文件轮转时可能出现竞争,导致日志错乱甚至丢失。
我的处理方案是:每个进程写独立文件,文件名里带上进程号:
python复制logger.add("app-{process}.log", rotation="50 MB", retention="7 days", enqueue=True)
如果你需要所有日志集中在同一个文件里,更省事的办法是让 Loguru 直接输出到标准输出(默认 sink),然后用 Docker、k8s、systemd 的日志收集机制去统一采集。日志切割和聚合交给底层基础设施,比自己在应用层硬扛要稳得多。
7.2 serialize=True:给你的日志平台喂结构化JSON
日志一旦进入 ELK、Loki、Datadog 这类平台,纯文本格式就没那么吃香了,结构化 JSON 更方便检索和聚合。Loguru 自带 serialize=True,一行就能输出 JSON 格式日志:
python复制logger.add("app.json", serialize=True, rotation="100 MB", retention="30 days")
输出的 JSON 会自动包含时间、级别、文件、函数、消息,以及你通过 bind() 绑定的所有 extra 字段。我之前就是靠这个把 request_id、stage、model、cost 这些字段直接透传到日志平台,再配上 Grafana 看板做可视化监控。对 LLM 应用来说,结构化日志几乎等同于可观测性的地基。
7.3 backtrace与diagnose:生产环境务必关掉
Loguru 默认的 backtrace 和 diagnose 是为了开发调试设计的,会把完整的变量值、调用栈上下文都打出来,方便定位。但这在生产环境是个隐患——异常日志里可能带上用户的 Prompt 内容,或者内部密钥。我在生产环境的 add() 里会显式关闭:
python复制logger.add(
"app.log",
level="INFO",
rotation="100 MB",
backtrace=False,
diagnose=False,
)
开发环境可以打开,但生产环境一定要关掉。这个坑看起来小,一旦日志泄露了敏感信息,影响范围可能非常大。
7.4 日志分级:INFO记结果,DEBUG记细节
最后说一个使用习惯上的建议。LLM 项目的 DEBUG 日志非常容易写多,一个请求打出十几条完整 Prompt/Response 是很常见的。如果把这些全部归为 INFO,日志平台一天的存储量会猛涨。
我的分级策略是:INFO 只记录"调了谁、花了多少、成功还是失败"这种摘要信息;DEBUG 才记录完整 Prompt、完整 Response、工具返回原文、检索命中的 chunk 内容。这样线上出问题时,INFO 已经能定位到具体链路,再需要细节时动态把某个服务的日志级别调到 DEBUG,临时抓取。Loguru 的 filter 机制配合配置中心或环境变量,完全可以做到不用重启服务就动态调整日志级别。
还有一个小技巧是:给长文本统一走 truncate(),给结构化字段用 bind(),给敏感内容做脱敏过滤。这三件事做完,日志体系基本就算稳了。
我在实际项目中最大的体会是,Loguru 不是那种"看起来很漂亮、但一上生产就露馅"的库。它把配置复杂度降到最低,同时保留了足够的灵活性,让我能在 LLM 这个日志量爆炸的领域里保持清晰的排障线索。如果你现在还在用 print 或者标准库 logging 硬扛 LLM 项目,真的可以花一个下午把日志体系切到 Loguru 试试。
