补日志之前,第二轮那条 500 的日志长这样:
json
{"level":"error","msg":"request failed","method":"POST","path":"/api/v1/chat","status":500,"latency_ms":1180}
补完之后,同一条请求长这样:
json
{"level":"error","msg":"agent_request_failed","trace_id":"7f3a9c21","session_id":"sess_8842","turn_index":2,"path":"/api/v1/chat","status":500,"model":"model-a","provider":"provider-a","prompt_version":7,"ctx_tokens":4180,"retrieve_ms":210,"llm_ms":0,"tool_ms":0,"total_ms":1180,"checkpoint_op":"read","err":"Error 1146: Table 'agent_prod.agent_checkpoint' doesn't exist"}
两行日志之间,隔了三轮排查。
这个项目是一个企业级 Agent,服务端是 Go,编排层是 Python,质量门禁是 665+ 条自动化测试。值班同学在群里丢过来上面第一行日志的时候,附了一句:「第一轮好好的,第二轮必挂,刷新又好了。」
如果一定要说少打了哪一个参数,答案是:错误原文,也就是 err 这个字段。它是三轮排查里唯一一个「打了就能立刻结案」的字段,也是最容易在设计时被漏掉的字段。下面按轮次拆。
一、三轮排查,每一轮都卡在同一个地方
第一轮,我们怀疑模型。
查了同一时间窗内模型服务的调用成功率和延迟,都没有异常。也想过是不是模板写崩了,但同一套模板第一轮能正常返回,说明模板不是变量。
这一轮真正有价值的产出,是确认了第一轮和第二轮的差别:第二轮需要读回上一轮的状态。但这条结论是从现象推出来的,不是从日志里看到的。日志里没有轮次,也没有任何能区分第一轮和第二轮的东西。
第二轮,我们怀疑超时和重试。
关掉重试直连后端复现,第二轮照样 500,假设排除。可这一轮暴露出一个更麻烦的问题:Go 业务服务的 access log 和 Python 侧的应用日志各自独立,没有共同的 trace_id,想看这一轮在状态层读了什么,只能靠时间戳去对。
对不上,就只能猜。
第三轮,我们停下来先补日志。
补齐入口日志字段之后重新复现,上面第二行日志直接跳了出来:checkpoint_op 是 read,err 是状态表不存在。
回头看,根因本身并不复杂:生产环境缺一张状态表,本地和测试环境由初始化脚本建表,生产走了另一套发布流程,这张表没进去。难的是在没有证据的情况下定位它。
有意思的是,把日志补齐之后,第一轮的日志也变了:
json
{"level":"warn","msg":"agent_request_swallowed_error","trace_id":"7f3a9c21","session_id":"sess_8842","turn_index":1,"path":"/api/v1/chat","status":200,"checkpoint_op":"write","err":"Error 1146: Table 'agent_prod.agent_checkpoint' doesn't exist","total_ms":1204}
第一轮其实也写失败了,只是那个错误被上游吞掉,响应照样返回 200。这条 warn 是补日志之后才有的,之前它完全不存在。
第一轮和第二轮报的是同一个错,区别只在于一个被吞了、一个没吞。这个差别,只有 err 字段能告诉你。
二、六组字段,各自解决一类「看不见」
那次补的入口日志字段一共六组。分组的依据不是字段类型,是「缺了它,你会看不见什么」。
| 字段组 | 作用 | 缺了会看不见什么 |
|---|---|---|
| trace_id | 贯穿 Go、Python、编排层的一次请求标识 | 跨服务日志串不起来,只能靠时间戳猜 |
| session_id + turn_index | 会话标识与轮次序号 | 分不清第一轮和第二轮,现象的规律性完全不可见 |
| model + provider | 实际调用的模型与供应商 | 多模型路由时不知道这一轮走的哪条路 |
| prompt_version + ctx_tokens | 模板版本与上下文长度 | 改了模板不知道是哪一版,上下文超限无从排查 |
| retrieve_ms + llm_ms + tool_ms + total_ms | 各层耗时拆分 | 只知道整体慢,不知道慢在哪一层 |
| checkpoint_op + err | 状态读写动作与错误原文 | 状态层报错被上游吞掉,现象和根因对不上 |
这六组里,最直接起作用的是 turn_index 和 checkpoint_op + err 这两组。
turn_index 让「只有第二轮挂」这件事从推测变成事实,把嫌疑范围从整个系统压到状态读写这一段。checkpoint_op + err 让错误原文直接落到日志里,不用再去猜第二次,也顺带把第一轮那个被吞掉的错误挖了出来。
剩下四组不是这次的功臣,但它们决定了下一次排查能不能少走两轮。轮次和错误原文解决的是「这次查得出来」,其余四组解决的是「下次查得快」。
三、中间件实现
下面是入口日志中间件的核心部分(Gin,其他框架同理)。它只做一件事:让任何一次异常都能在日志里直接回答轮次、层级、耗时和错误位置。
go
package middleware
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// costAccumulator 收集下游各层的耗时,出口统一打点。
type costAccumulator struct {
retrieveMs int64
llmMs int64
toolMs int64
}
func (a *costAccumulator) Add(kind string, d time.Duration) {
switch kind {
case "retrieve":
a.retrieveMs += d.Milliseconds()
case "llm":
a.llmMs += d.Milliseconds()
case "tool":
a.toolMs += d.Milliseconds()
}
}
func newTraceID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// TurnLogger 是 Agent 接口的入口日志中间件。
func TurnLogger(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
ctx := c.Request.Context()
// 优先沿用上游 trace_id,没有才生成,保证跨服务能串起来
traceID := c.GetHeader("X-Trace-Id")
if traceID == "" {
traceID = newTraceID()
}
c.Set("trace_id", traceID)
c.Header("X-Trace-Id", traceID)
turn, _ := strconv.Atoi(c.GetHeader("X-Turn-Index"))
acc := &costAccumulator{}
c.Set("cost", acc)
c.Next()
status := c.Writer.Status()
// 错误原文无条件采集,不只在 5xx 分支里取。
// 第一轮写入失败被吞掉时响应是 200,只有这里能留下痕迹。
var errText string
if e := c.Errors.Last(); e != nil {
errText = e.Error()
}
attrs := []slog.Attr{
slog.String("trace_id", traceID),
slog.String("session_id", c.GetHeader("X-Session-Id")),
slog.Int("turn_index", turn),
slog.String("path", c.FullPath()),
slog.Int("status", status),
slog.String("model", c.GetString("model")),
slog.String("provider", c.GetString("provider")),
slog.Int("prompt_version", c.GetInt("prompt_version")),
slog.Int("ctx_tokens", c.GetInt("ctx_tokens")),
slog.Int64("retrieve_ms", acc.retrieveMs),
slog.Int64("llm_ms", acc.llmMs),
slog.Int64("tool_ms", acc.toolMs),
slog.Int64("total_ms", time.Since(start).Milliseconds()),
slog.String("checkpoint_op", c.GetString("checkpoint_op")),
slog.String("err", errText),
}
level, msg := slog.LevelInfo, "agent_request_ok"
switch {
case status >= 500:
level, msg = slog.LevelError, "agent_request_failed"
case errText != "":
// 状态正常但链路里出过错,单独标出来,这是最容易被漏掉的一类
level, msg = slog.LevelWarn, "agent_request_swallowed_error"
}
log.LogAttrs(ctx, level, msg, attrs...)
}
}
耗时字段需要下游配合累加,在调用检索和模型的地方各加一行:
go
acc := c.MustGet("cost").(*costAccumulator)
start := time.Now()
docs, rerr := retriever.Retrieve(ctx, q)
acc.Add("retrieve", time.Since(start))
if rerr != nil {
c.Error(rerr) // 记进 c.Errors,出口统一落日志
}
顺带解释一个容易被拿计算器对账的细节:这条日志里 retrieve_ms 是 210,llm_ms 和 tool_ms 都是 0,加起来远小于 total_ms 1180。差额是鉴权、编排调度和响应序列化的开销,分项耗时并不覆盖全部路径。所以 total_ms 必须和分项一起打,只打分项会让人以为系统在凭空消耗时间,只打总量又定位不到层。
补字段这件事本身也踩过一个坑,值得记一笔。第一版中间件写完跑起来,err 仍然是空的。查了半天才发现,业务代码捕获到错误之后只打了自己的日志,没有调用 c.Error(err),错误压根没进 c.Errors。字段设计得再全,错误不往这个口子里送,中间件也拿不到。后来我们把它写成了一条代码评审的硬检查项:捕获错误的地方,必须同时 c.Error(err)。
三个实现要点:
一是 X-Trace-Id 必须透传给下游 Python 侧,否则跨语言仍然串不起来。透传优先于生成。
二是错误原文无条件采集,不能只在 5xx 分支里取。第一轮那个被吞掉的错误,恰恰是这条设计挖出来的。
三是 c.Error(err) 要真的调用。错误不进 c.Errors,中间件再完善也拿不到原文。原则落不到代码里,就只是口号。
四、超时阈值与重试边界,也要有可观测性
排查 500 时,超时和重试是最容易互相甩锅的一对。处理办法是分开定义,各自留证。
调用模型服务时,超时按连接、首字节、整体三段分别设置:
go
import (
"net"
"net/http"
"time"
)
// 示例值,按业务改:只等首字节,整体另设上限
cli := &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
},
Timeout: 30 * time.Second,
}
重试既要设次数上限,也要区分错误类型,而且每次判定都要留日志,事后才能看出重试到底是在帮忙还是在添乱:
go
import (
"context"
"errors"
"log/slog"
"net"
)
// 示例值:最多重试 2 次
func shouldRetry(ctx context.Context, log *slog.Logger, err error, attempt int, traceID string) bool {
if attempt >= 2 {
log.LogAttrs(ctx, slog.LevelWarn, "retry_skipped",
slog.String("trace_id", traceID),
slog.Int("attempt", attempt),
slog.String("reason", "attempt_limit"),
)
return false
}
// 超时判断必须放在最前:被 url.Error 包装的拨号超时同样满足
// errors.As(err, &ne),放在后面会被误判成「网络抖动,可重试」。
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
log.LogAttrs(ctx, slog.LevelWarn, "retry_skipped",
slog.String("trace_id", traceID),
slog.Int("attempt", attempt),
slog.String("reason", "timeout"),
)
return false // 超时不重试,再试一次只会把队列堵得更死
}
if ne != nil {
log.LogAttrs(ctx, slog.LevelInfo, "retry_accepted",
slog.String("trace_id", traceID),
slog.Int("attempt", attempt),
slog.String("reason", "network"),
)
return true // 非超时的网络错误,按瞬时抖动处理
}
log.LogAttrs(ctx, slog.LevelInfo, "retry_skipped",
slog.String("trace_id", traceID),
slog.Int("attempt", attempt),
slog.String("reason", "not_retryable"),
)
return false
}
为什么超时不重试,值得单独说一句。Agent 单轮耗时本来就长,超时说明这一轮已经把资源占了很久,再发一次等于把并发额度翻倍吃掉。重试只留给能确认为瞬时抖动的错误,其余一律交回上层,让人或者队列来决定。
这个判定顺序是我踩过的坑。最初版本把网络错误判断写在前面,结果拨号超时被判成可重试,重试又超时,链条直接堵死。判断顺序这种事,错了不会报错,只会让你在压测时莫名其妙。
另外提一句,这里没有为上层 ctx 到期单独写分支。context.DeadlineExceeded 本身就是一个 Timeout() 返回 true 的 net.Error,会直接落进上面的超时分支,返回值同样是不重试。单独再判一次属于写给自己看的安慰,运行时永远走不到。
五、可以复用的排查顺序
第一步,先找规律。失败是集中在第几轮、哪种输入、哪个时段?规律本身就是最便宜的证据,它能把嫌疑范围收窄一大半,而且不依赖任何额外工具。
第二步,看日志能不能一次回答「第几轮、错了什么」。回答不了就先补日志,再继续查。补日志的成本是一个中间件,猜的成本是三轮。
第三步,别先看模型。模型侧通常有最完整的调用指标,成功率、延迟、限流都是现成的,反而最容易排除。真正查不动的,往往是你自己写的那几层,因为那些层你没给它装过仪表。
第四步,拿到错误原文再下结论。被吞掉的错误是排查里最贵的东西,它让现象和根因对不上。
这套顺序不依赖特定框架,换语言、换编排库都成立。它也不是凭空总结出来的方法论,就是从上面那三轮里一句一句抠出来的:第一轮教会我们先找规律,第二轮教会我们先看日志够不够,第三轮教会我们先补证据再下结论。
六、收尾
那次之后,我们给这个项目加了一条硬规矩:任何一层写状态,错误必须往上传,并且落到入口日志的字段里。
线上故障不可怕,可怕的是你的日志让你查不动。补日志这件事,优先级应该排在调模型前面。
你手上的 Agent 项目,入口日志里有没有 turn_index 和错误原文这两个字段?评论区聊聊你们排查 500 时最常卡在哪一层。