从请求到上游:实现一条可观测的多模型调用链

从请求到上游:实现一条可观测的多模型调用链

一个 OpenAI 兼容接口返回了 429,问题可能不在客户端,也不一定是模型供应商限流。请求进入多模型网关后,往往还会经过鉴权、用户分组、模型映射、渠道筛选、失败重试、流式转发、用量结算等环节。只记录一句"请求失败",排查时就只能在多套日志之间猜测。

真正有用的可观测性,不是先上一个复杂的链路追踪平台,而是先回答五个问题:这是哪个客户端请求?实际选中了哪个渠道?上游如何标识这次调用?首字和总耗时分别是多少?最终按什么用量结算?

下面以 Go、Gin 和一个多模型 API 网关为例,实现一条可以落库、检索和逐步扩展的调用链。

一、先定义最小闭环,而不是先堆日志

一条可排查的多模型调用链至少要有以下字段:

字段 作用 典型来源
request_id 串联本地所有阶段 网关入口生成
upstream_request_id 向供应商追查请求 上游响应头或响应体
model 用户请求的原始模型 请求参数
channel_id 实际命中的渠道 路由结果
retry_index 第几次渠道尝试 重试循环
is_stream 区分流式和非流式 请求参数
first_response_ms 首字延迟 首个响应片段到达时计算
total_ms 端到端总耗时 请求结束时计算
prompt_tokenscompletion_tokens 对账与容量分析 上游用量或本地估算
quota 最终扣费结果 结算阶段

这里最重要的设计是:request_id 属于网关,upstream_request_id 属于供应商。两者不能互相覆盖。否则经过多级中转时,本地日志会失去稳定主键,供应商又无法根据自己的 ID 协助定位。

二、在入口生成唯一请求 ID

入口中间件只做四件事:生成 ID、写入 Gin 上下文、写入标准 context.Context、回传给客户端。

go 复制代码
func RequestID() gin.HandlerFunc {
	return func(c *gin.Context) {
		requestID := newRequestID()

		c.Set("X-Gateway-Request-Id", requestID)
		ctx := context.WithValue(
			c.Request.Context(),
			requestIDContextKey{},
			requestID,
		)
		c.Request = c.Request.WithContext(ctx)
		c.Header("X-Gateway-Request-Id", requestID)

		c.Next()
	}
}

为什么同时写两份上下文?Gin 处理器和中间件通常直接读取 *gin.Context,而数据库、HTTP 客户端、日志库更习惯接收标准 context.Context。入口统一写入后,后续业务函数不需要重新生成 ID,也不会出现"控制器有 ID、服务层没有 ID"的断链。

客户端拿到响应头后,也能在报错工单中直接提供 request_id。这比让用户复制一整段响应体更稳定,也更安全。

三、用一个运行态对象承载路由事实

请求进入路由层后,需要把会变化的状态集中起来。不要让模型名、渠道 ID、重试次数和计费信息散落在多个局部变量里。

go 复制代码
type RelayTrace struct {
	RequestID          string
	UpstreamRequestID  string
	OriginModel        string
	ResolvedModel      string
	ChannelID          int
	UsingGroup         string
	RetryIndex         int
	IsStream           bool
	StartedAt          time.Time
	FirstResponseAt    time.Time
	PromptTokens       int
	CompletionTokens   int
	Quota              int
}

func (trace *RelayTrace) MarkFirstResponse(now time.Time) {
	if trace.FirstResponseAt.IsZero() {
		trace.FirstResponseAt = now
	}
}

OriginModelResolvedModel 也应该分开。例如客户端请求一个别名,路由规则将它映射到具体版本;发生"模型不存在"时,必须知道用户传了什么,以及网关最终向上游发送了什么。

重试时只更新真实变化的字段,例如 ChannelIDUsingGroupRetryIndexRequestID 必须保持不变,这样一次客户端请求的所有渠道尝试仍属于同一条主链路。

四、保留上游请求 ID,但不要覆盖本地 ID

许多供应商会在响应头返回请求标识。转发响应头时,可以捕获该值用于日志,但不能把它原样当成本地请求 ID 回传。

go 复制代码
func shouldCopyUpstreamHeader(
	c *gin.Context,
	key string,
	values []string,
) bool {
	if strings.EqualFold(key, "Content-Length") {
		return false
	}

	if strings.EqualFold(key, "X-Gateway-Request-Id") {
		if len(values) > 0 {
			c.Set("X-Upstream-Request-Id", values[0])
		}
		return false
	}

	return true
}

这里返回 false 有两个原因。第一,本地响应头应继续保留网关生成的 ID;第二,上游的同名头只作为证据保存。如果供应商使用 x-request-idrequest-id 或在 JSON 响应中返回 ID,可以在各适配器里归一化写入 UpstreamRequestID

对于跨渠道重试,还可以额外记录每次尝试的上游 ID:

go 复制代码
type ChannelAttempt struct {
	Index             int    `json:"index"`
	ChannelID         int    `json:"channel_id"`
	UpstreamRequestID string `json:"upstream_request_id,omitempty"`
	HTTPStatus        int    `json:"http_status"`
	ErrorCode         string `json:"error_code,omitempty"`
	DurationMS        int64  `json:"duration_ms"`
}

主日志保存最终结果,attempts 保存过程。这样既能快速检索,也不会把每个重试都伪装成新的客户端请求。

五、流式请求必须拆分首字耗时和总耗时

非流式请求通常只关心总耗时,流式请求则不同:用户对"快不快"的感知主要由首个有效数据片段决定。如果只在连接关闭后记录耗时,一个 300 毫秒开始输出、持续生成 20 秒的请求,会被误判为 20 秒才响应。

go 复制代码
startedAt := time.Now()
firstChunkRecorded := false

for {
	chunk, err := readUpstreamChunk()
	if len(chunk) > 0 && !firstChunkRecorded {
		trace.MarkFirstResponse(time.Now())
		firstChunkRecorded = true
	}

	if len(chunk) > 0 {
		if _, writeErr := client.Write(chunk); writeErr != nil {
			return writeErr
		}
	}

	if errors.Is(err, io.EOF) {
		break
	}
	if err != nil {
		return err
	}
}

totalMS := time.Since(startedAt).Milliseconds()
firstResponseMS := trace.FirstResponseAt.Sub(startedAt).Milliseconds()

还要注意:心跳、空行和供应商自定义 ping 不一定代表模型已经开始输出。首字计时应以第一个对客户端有意义的事件为准,否则指标会被"连接建立成功"提前美化。

六、把调用结果和计费结果写入同一条消费日志

链路日志与计费日志完全分离,会导致另一个常见问题:请求明明失败了,为什么还扣费;或者请求成功了,为什么用量为零。更实用的做法是在结算完成后,写入同一条消费记录。

go 复制代码
type ConsumeLog struct {
	RequestID          string `json:"request_id"`
	UpstreamRequestID  string `json:"upstream_request_id,omitempty"`
	UserID             int    `json:"user_id"`
	Model               string `json:"model"`
	ChannelID           int    `json:"channel_id"`
	PromptTokens        int    `json:"prompt_tokens"`
	CompletionTokens    int    `json:"completion_tokens"`
	Quota               int    `json:"quota"`
	FirstResponseMS     int64  `json:"first_response_ms"`
	TotalMS             int64  `json:"total_ms"`
	IsStream            bool   `json:"is_stream"`
}

写日志的时机要放在最终结算之后,而不是刚收到 HTTP 200 时。原因是 HTTP 200 只说明协议层收到响应,不代表流式内容完整、不代表用量可解析,也不代表差额结算成功。

敏感信息不要写进可观测字段。完整 Key、Authorization 头、提示词正文和用户上传内容都不应该成为默认日志。需要排查输入差异时,可以保存经过脱敏的请求摘要、参数白名单或哈希,而不是原文。

七、让日志真正可检索

有字段但查不到,等于没有。后台查询至少应支持本地请求 ID 和上游请求 ID 的精确过滤:

go 复制代码
func applyTraceFilters(
	db *gorm.DB,
	requestID string,
	upstreamRequestID string,
) *gorm.DB {
	if requestID != "" {
		db = db.Where("request_id = ?", requestID)
	}
	if upstreamRequestID != "" {
		db = db.Where("upstream_request_id = ?", upstreamRequestID)
	}
	return db
}

建议给两个字段建立普通索引,并按创建时间倒序展示。精确查询不要用 %keyword% 模糊匹配,否则数据量上来后既慢,又容易把相似 ID 混在一起。

拿到一次失败请求后,排查顺序可以固定为:

  1. request_id 找到完整消费日志;
  2. 检查原始模型、映射模型、分组和最终渠道;
  3. 查看 attempts,确认是否发生跨渠道重试;
  4. upstream_request_id 对照供应商日志或提交工单;
  5. 对比首字耗时与总耗时,区分连接慢、生成慢和中途断流;
  6. 最后核对用量来源、预扣、结算和退款状态。

八、三个容易踩坑的实现细节

第一个坑是每次重试重新生成请求 ID。这样会把一次请求拆成多条互不相关的记录。正确做法是主 ID 不变,尝试序号递增。

第二个坑是把上游 ID 直接回传并覆盖本地 ID。多级代理时,最外层系统会失去自己的稳定索引。应该同时保留两类 ID,并明确命名。

第三个坑是看到 HTTP 200 就记成功。对于 SSE 或 WebSocket,请求可能在后续解析、转发或结算阶段失败。成功状态应由业务协议完整性和最终结算共同决定。

九、从最小字段开始,再接入 Trace 系统

当请求 ID、渠道尝试、时延和计费已经能在数据库里闭环后,再接 OpenTelemetry 会简单很多。可以把 request_id 作为日志关联字段,把每次渠道尝试建成 span,并给 span 增加 modelchannel_idretry_indexupstream_request_id 属性。

不要反过来:如果业务字段本身没有定义清楚,即使接入了完整的 Trace UI,也只会得到一条漂亮但无法解释计费与路由结果的瀑布图。

十、用 FishAI 完成一次真实链路验收

实现完上述字段后,下一步不是继续增加日志,而是用一条真实请求验证它们能否串起来。FishAI 是一个 OpenAI 兼容的多模型 API 入口,官方地址是 yufish.cc。开发者可以在官方页面查看当前文档和可用模型,再使用自己账户创建的 Key 发起一次短输入、低输出的最小请求。

这次验收只需要保留四项脱敏证据:客户端收到的本地请求 ID、实际模型、最终状态码和消费记录;如果响应中还能取得上游请求 ID,再用它对照渠道尝试。这样可以验证本文的入口、路由、上游和结算字段是否真的形成闭环,而不是只在示例代码里成立。

建议先打开 FishAI 官方站 yufish.cc,按当前文档完成一次最小调用,再用本文的查询顺序复盘该请求。模型、价格和接口能力以官方页面实时展示为准,文章不使用旧截图或固定数字代替当前配置。

可观测多模型调用链的价值不在于"日志更多",而在于让一次请求从入口、路由、上游到结算都能被同一个 ID 找回。先把这条最小闭环做扎实,429、模型不可用、流式中断和计费争议才会从猜测变成可复现、可归因的问题。

相关推荐
newerp2 小时前
Golang 切片底层结构
后端·程序员·go
运维开发笔记17 小时前
8.2 Go Struct 方法学习笔记
go
Go_error20 小时前
Fyne:让 Go 开发者也能玩转 GUI
后端·go
Go_error20 小时前
Go 实现 Mysql AES 与 Scanner/Valuer 自动加解密
go
Go_error20 小时前
Badu/bus:Go 轻量级泛型发布/订阅事件总线
后端·go
Go_error20 小时前
Go-redis:执行 Lua 脚本
后端·go
stark张宇1 天前
Go并发调度器源码探秘:GMP模型之G/M/P底层数据结构完全拆解
go
学习星球2 天前
# 6G通感一体化(ISAC)技术深度解析——从原理到实战> <br />
go·信息与通信·媒体
小满zs2 天前
Go语言第十章(指针)
后端·google·go