llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
115 lines
5.4 KiB
Go
115 lines
5.4 KiB
Go
package llm
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// 空补全:一次模型调用回来,**正文、工具调用、思考内容三样全没有**。
|
||
//
|
||
// 为什么单独立一个错误:网关把 SSE 流"干净地关掉"(不发 [DONE]/message_stop,
|
||
// 也不触发静默看门狗)时,provider 会返回一条空 Message + nil error,而 agent
|
||
// 的收尾判定是"这条没有 tool_calls ⇒ 模型说完了"——于是回合被当成**成功结束**,
|
||
// 界面一个字都不出。用户看到的症状是"干不了几步就停,得手动按继续"。
|
||
// 真实取证:20261009-213751 一次 136 步的会话里有 18 个这样的空回合
|
||
// (全部 usage 缺失、中位 3.0s、12 个紧跟在工具结果之后),对应 23 次人工「继续」。
|
||
//
|
||
// 判定刻意**不依赖终止事件**:有些兼容网关确实不发 [DONE],拿"没收到终止事件"
|
||
// 当条件是会把能用的配置弄坏的。"整轮什么都没有"则是无歧义的——一个 agent 回合
|
||
// 里什么都没有,永远不是一次有用的最终回答。
|
||
|
||
// ErrEmptyCompletion 是"这一轮什么都没返回"。可被 errors.Is 识别,上层据此重试。
|
||
var ErrEmptyCompletion = errors.New("模型返回空回复(没有正文、没有工具调用、没有思考内容)")
|
||
|
||
// IsEmptyCompletion 判断一次补全是否什么都没有。usage 不参与判定——
|
||
// 观测到的坏流量 usage 也缺失,但"有 usage 却整轮空"同样不该被当成最终回答。
|
||
func IsEmptyCompletion(m Message) bool {
|
||
return strings.TrimSpace(m.Content) == "" &&
|
||
strings.TrimSpace(m.Reasoning) == "" &&
|
||
len(m.ToolCalls) == 0
|
||
}
|
||
|
||
// ErrTruncatedStream 是"流被掐断":既没收到任何终止事件,也没拿到 usage。
|
||
//
|
||
// 为什么两种信号都要缺才算:网关不发 [DONE] 的情况真实存在(拿"没收到终止事件"
|
||
// 单独当条件会把这些配置弄坏),而有些网关确实不回 usage。两个都没有才算证据——
|
||
// 一次正常的补全至少留下其中一个。
|
||
//
|
||
// 咬人的样子(会话 20261010-092121 实测):一次 62 秒的调用只吐出 "rawData" 一个词、
|
||
// usage 缺失、没有工具调用,而全库 179 条 assistant 只有这一条 usage 缺失。它被
|
||
// "没有 tool_calls ⇒ 模型说完了"当成正常收尾,用户只能打「继续」。
|
||
var ErrTruncatedStream = errors.New("模型回复被截断(流里既没有终止事件也没有 usage)")
|
||
|
||
// streamState 是一次流式调用的收尾证据,由各家 provider 在解析循环里填。
|
||
type streamState struct {
|
||
usageSeen bool // 收到过 usage 块
|
||
doneSeen bool // 收到 [DONE] / message_stop / response.completed / finish_reason
|
||
}
|
||
|
||
// outputLimitReasons 是三家里"输出被 token 上限切断"的终止原因。
|
||
// 各家叫法不同(Chat Completions 用 length,Anthropic 用 max_tokens,Responses 用
|
||
// max_output_tokens),归成一个信号给上层,别让 agent 认三家方言。
|
||
//
|
||
// 刻意**不含** model_context_window_exceeded:那是上下文满了,续写只会连着撞同一堵墙,
|
||
// 该走的是压缩(agent/context.go),不是"接着写"。
|
||
var outputLimitReasons = map[string]bool{
|
||
"length": true,
|
||
"max_tokens": true,
|
||
"max_output_tokens": true,
|
||
}
|
||
|
||
// IsTransientStream 报告一次失败属于"这条请求没走完 / 网关没答上"——重发同一个请求
|
||
// 就有机会拿到完整回复。与之相对的是**确定性**失败(HTTP 400 说请求本身不合法、鉴权
|
||
// 失败、上下文满),重发只会再撞同一堵墙,所以那些不该重试。
|
||
//
|
||
// 三类证据:整轮空、流被截断、流卡死到空闲看门狗上限;再加一组传输层措辞(连接被掐、
|
||
// 半路 EOF、网关 429/5xx)。它们都发生在流已开始之后,llm 那层的前置重试
|
||
// (doWithRetry 只覆盖"响应开始前")管不到,只能由上层按请求重发。
|
||
func IsTransientStream(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
if errors.Is(err, ErrEmptyCompletion) || errors.Is(err, ErrTruncatedStream) {
|
||
return true
|
||
}
|
||
var stalled *StalledError
|
||
if errors.As(err, &stalled) {
|
||
return true
|
||
}
|
||
msg := strings.ToLower(err.Error())
|
||
for _, mark := range transientErrorMarks {
|
||
if strings.Contains(msg, mark) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// transientErrorMarks 是传输层失败的措辞。刻意不含 cancel:人被叫停时不该再发请求。
|
||
var transientErrorMarks = []string{
|
||
"connection reset", "connection refused", "broken pipe", "i/o timeout",
|
||
"eof", "too many requests", "http 429", "http 500", "http 502", "http 503", "http 504",
|
||
}
|
||
|
||
func marksOutputLimit(reason string) bool {
|
||
return outputLimitReasons[strings.ToLower(strings.TrimSpace(reason))]
|
||
}
|
||
|
||
// completion 是三家 provider 的统一出口:空回复与截断流都按失败返回。
|
||
// prefix 带上协议名,报错时能看出是哪一路 provider 撞的。
|
||
func completion(prefix string, m Message, st streamState) (Message, error) {
|
||
if IsEmptyCompletion(m) {
|
||
return Message{}, fmt.Errorf("%s: %w", prefix, ErrEmptyCompletion)
|
||
}
|
||
if !st.usageSeen && !st.doneSeen {
|
||
return Message{}, fmt.Errorf("%s: %w", prefix, ErrTruncatedStream)
|
||
}
|
||
// 带工具调用的响应不算"被切断的最终回答":那时 finish_reason 通常是 tool_calls,
|
||
// 而真是 length 也说明参数 JSON 已经残缺——续写救不回来,该重发这一步(另一件事)。
|
||
if len(m.ToolCalls) > 0 {
|
||
m.OutputLimited = false
|
||
}
|
||
return m, nil
|
||
}
|