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。
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
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()
|
||
}
|