Files
AutoZero/llm/streamguard.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

129 lines
4.1 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 (
"io"
"net"
"net/http"
"sync"
"time"
)
// streamIdleTimeout 是流式响应允许的最长静默。
//
// 为什么需要它:三家 provider 都是 `bufio.Scanner` 死读 resp.Body,而 ctx 一路
// 传下去却**没有任何超时**(客户端也是无超时的 http.DefaultClient)。上游"连上了
// 但不吐字节"(网关半开连接、服务端卡在生成阶段)时,Read 永久阻塞——整个回合
// 就此静止,用户既等不到结果也看不出出了什么。这是第二类"卡死",且比 bash 那条
// 更容易在长任务里撞上(长任务 = 长思考 = 长静默)。
//
// 90 秒是权衡后的值:推理模型的首 token 延迟与中途长思考都会静默,几十秒属正常,
// 而真正卡死的连接通常连几十秒都不返回。宁可宽一点,也不要错杀慢但活的网关。
// var 仅供测试缩短。
var streamIdleTimeout = 90 * time.Second
// defaultHTTPClient 是 provider 没显式给 HTTP 客户端时的默认值。
//
// 不用 http.DefaultClient:① 它是进程级全局,改它会影响别人的代码,不改它就没有
// 任何超时;② 这里要的是"首字节前有限等待",而 Client.Timeout 覆盖**整个响应体**,
// 设了就会把正常的长流式掐死。所以只设 ResponseHeaderTimeout + dial/TLS 超时,
// 读流阶段的看门狗交给 idleGuard。
var defaultHTTPClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConnsPerHost: 8,
},
}
// idleGuard 是包在 SSE 响应体外面的静默看门狗:每次读到字节就重置计时,
// 静默超阈值就关掉连接,从而解阻塞正在 Read 的那个 goroutine。
type idleGuard struct {
body io.ReadCloser
idle time.Duration
mu sync.Mutex
last time.Time
once sync.Once
stalled bool
done chan struct{}
}
// guardStream 开始看护 body,返回可直接交给 bufio.Scanner 的 io.Reader。
// 调用方必须 defer Stop(),否则看护 goroutine 会活到进程结束。
func guardStream(body io.ReadCloser, idle time.Duration) *idleGuard {
g := &idleGuard{body: body, idle: idle, last: time.Now(), done: make(chan struct{})}
go g.watch()
return g
}
func (g *idleGuard) Read(p []byte) (int, error) {
n, err := g.body.Read(p)
if n > 0 {
g.mu.Lock()
g.last = time.Now()
g.mu.Unlock()
}
return n, err
}
func (g *idleGuard) Close() error { return g.body.Close() }
// watch 周期检查静默时长。阈值本身可能很长(90s),tick 取 idle/4:
// 既不会让正常流多出可观开销,也能在半分钟内发现"再也不会有数据"的连接。
func (g *idleGuard) watch() {
tick := g.idle / 4
if tick < 20*time.Millisecond {
tick = 20 * time.Millisecond // 测试里 idle 会被缩到毫秒级
}
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-g.done:
return
case <-t.C:
g.mu.Lock()
quiet := time.Since(g.last)
g.mu.Unlock()
if quiet > g.idle {
g.once.Do(func() {
g.mu.Lock()
g.stalled = true
g.mu.Unlock()
// 唯一的解阻塞手段:Read 卡在传输层时没有任何回调可指望。
_ = g.body.Close()
})
return
}
}
}
}
// Stop 结束看护(流正常读完后调用)。
func (g *idleGuard) Stop() { close(g.done) }
// Err 报告这次流是不是被看门狗掐断的。给上层的意义是"能说出真实原因":
// 否则调用方只会看到一句来历不明的 "read tcp: use of closed connection"。
func (g *idleGuard) Err() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.stalled {
return &StalledError{Idle: g.idle}
}
return nil
}
// StalledError 是"服务端长时间不吐字节"这一类失败。可被上层 errors.As 识别。
type StalledError struct{ Idle time.Duration }
func (e *StalledError) Error() string {
return "provider stream stalled: no data for " + e.Idle.String()
}