Agent 第一句就 500,我们查了三轮:入口日志少打了一个参数

补日志之前,第二轮那条 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 时最常卡在哪一层。

相关推荐
羑悻1 小时前
穿越Docker内核迷雾:揭秘镜像分层存储的叠加态与卷挂载的多维空间穿梭技术
后端·docker·容器
励志不掉头发的内向程序员1 小时前
从鼠标点击到画出一条线:CAD 交互层的状态机设计
后端·架构
Feynman’s boom1 小时前
SRE Agent模型输出的防幻觉校验
agent·imagen·ai运维
用户EasyAdminBlazor1 小时前
EasyAdminBlazor 日志系统源码解析:为什么数据库日志需要有界队列?
后端
Sylven1 小时前
【DevOps 开发流程】问题+标签驱动开发,可视化你和AI的开发进度
后端
松就是我902981 小时前
Agent系统设计七条通用原则-CSDN
后端
136096757231 小时前
repo 与 index:站点目录该怎么切
后端
bullkingluo1 小时前
从零到一搭建企业级智能问答系统:Ch14 · 三层记忆与断点续跑
架构·llm·agent
那就叫王师傅1 小时前
AD基础学习01
后端