从请求到上游:实现一条可观测的多模型调用链
一个 OpenAI 兼容接口返回了 429,问题可能不在客户端,也不一定是模型供应商限流。请求进入多模型网关后,往往还会经过鉴权、用户分组、模型映射、渠道筛选、失败重试、流式转发、用量结算等环节。只记录一句"请求失败",排查时就只能在多套日志之间猜测。
真正有用的可观测性,不是先上一个复杂的链路追踪平台,而是先回答五个问题:这是哪个客户端请求?实际选中了哪个渠道?上游如何标识这次调用?首字和总耗时分别是多少?最终按什么用量结算?
下面以 Go、Gin 和一个多模型 API 网关为例,实现一条可以落库、检索和逐步扩展的调用链。
一、先定义最小闭环,而不是先堆日志
一条可排查的多模型调用链至少要有以下字段:
| 字段 | 作用 | 典型来源 |
|---|---|---|
request_id |
串联本地所有阶段 | 网关入口生成 |
upstream_request_id |
向供应商追查请求 | 上游响应头或响应体 |
model |
用户请求的原始模型 | 请求参数 |
channel_id |
实际命中的渠道 | 路由结果 |
retry_index |
第几次渠道尝试 | 重试循环 |
is_stream |
区分流式和非流式 | 请求参数 |
first_response_ms |
首字延迟 | 首个响应片段到达时计算 |
total_ms |
端到端总耗时 | 请求结束时计算 |
prompt_tokens、completion_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
}
}
OriginModel 和 ResolvedModel 也应该分开。例如客户端请求一个别名,路由规则将它映射到具体版本;发生"模型不存在"时,必须知道用户传了什么,以及网关最终向上游发送了什么。
重试时只更新真实变化的字段,例如 ChannelID、UsingGroup 和 RetryIndex。RequestID 必须保持不变,这样一次客户端请求的所有渠道尝试仍属于同一条主链路。
四、保留上游请求 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-id、request-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 混在一起。
拿到一次失败请求后,排查顺序可以固定为:
- 用
request_id找到完整消费日志; - 检查原始模型、映射模型、分组和最终渠道;
- 查看
attempts,确认是否发生跨渠道重试; - 用
upstream_request_id对照供应商日志或提交工单; - 对比首字耗时与总耗时,区分连接慢、生成慢和中途断流;
- 最后核对用量来源、预扣、结算和退款状态。
八、三个容易踩坑的实现细节
第一个坑是每次重试重新生成请求 ID。这样会把一次请求拆成多条互不相关的记录。正确做法是主 ID 不变,尝试序号递增。
第二个坑是把上游 ID 直接回传并覆盖本地 ID。多级代理时,最外层系统会失去自己的稳定索引。应该同时保留两类 ID,并明确命名。
第三个坑是看到 HTTP 200 就记成功。对于 SSE 或 WebSocket,请求可能在后续解析、转发或结算阶段失败。成功状态应由业务协议完整性和最终结算共同决定。
九、从最小字段开始,再接入 Trace 系统
当请求 ID、渠道尝试、时延和计费已经能在数据库里闭环后,再接 OpenTelemetry 会简单很多。可以把 request_id 作为日志关联字段,把每次渠道尝试建成 span,并给 span 增加 model、channel_id、retry_index 和 upstream_request_id 属性。
不要反过来:如果业务字段本身没有定义清楚,即使接入了完整的 Trace UI,也只会得到一条漂亮但无法解释计费与路由结果的瀑布图。
十、用 FishAI 完成一次真实链路验收
实现完上述字段后,下一步不是继续增加日志,而是用一条真实请求验证它们能否串起来。FishAI 是一个 OpenAI 兼容的多模型 API 入口,官方地址是 yufish.cc。开发者可以在官方页面查看当前文档和可用模型,再使用自己账户创建的 Key 发起一次短输入、低输出的最小请求。
这次验收只需要保留四项脱敏证据:客户端收到的本地请求 ID、实际模型、最终状态码和消费记录;如果响应中还能取得上游请求 ID,再用它对照渠道尝试。这样可以验证本文的入口、路由、上游和结算字段是否真的形成闭环,而不是只在示例代码里成立。
建议先打开 FishAI 官方站 yufish.cc,按当前文档完成一次最小调用,再用本文的查询顺序复盘该请求。模型、价格和接口能力以官方页面实时展示为准,文章不使用旧截图或固定数字代替当前配置。
可观测多模型调用链的价值不在于"日志更多",而在于让一次请求从入口、路由、上游到结算都能被同一个 ID 找回。先把这条最小闭环做扎实,429、模型不可用、流式中断和计费争议才会从猜测变成可复现、可归因的问题。