Files
AutoZero/llm/completion.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
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。
2026-10-26 00:54:46 +08:00

115 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}