fix(#23)+feat(#24): stop 后不再下发周期 usage 帧;gummy 预连接 spare 会话
ci / server (push) Failing after 9s
ci / design-tokens (push) Failing after 10s

#23 修复(服务端根治,五端客户端提交信号自动变安全):
- finish 先冻结会话并停 usageLoop,再 flush provider——此前 usageLoop 在
  waitResults(最长 3s)期间继续 tick,周期 usage 帧会抢在尾部 final 之前
  下发,客户端以 partial 文本提前上屏、丢失 final 修正(12B 实测 8s 会话
  4/4 复现;修复后 6/6 归零)
- 桌面端收尾兜底超时 350ms → 800ms(实测 8s 会话 final flush 需 350~540ms,
  350ms 会截丢尾 final)

#24 预连接(12B 调优):
- GummyProvider 常备一条已完成 run-task 握手的 spare 会话,start 直取,
  后台异步补位 + 40s 定期换新(DashScope 空闲 60s 断连,实测 60s 存活/
  120s Idle timeout,留余量 45s)
- 实测网关 start 处理 120~670ms → 3~4ms;消除 dial 抖动(实测 60ms~3.7s)
  对 first_partial 尾部的放大

gummycheck 增加 -model / -pace / -idle 参数,支持模型对比与闲置存活实验

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 19:44:25 +08:00
parent 7b693b0f63
commit d22e02fa2e
4 changed files with 135 additions and 13 deletions
+108
View File
@@ -22,8 +22,25 @@ const dashscopeWS = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
type GummyProvider struct {
APIKey string
Model string // 默认 gummy-realtime-v1
// spare 预连接(12B/#24):后台常备一条已完成 run-task 握手的 DashScope
// 会话,start 直接取用,把 dial+task-started(实测暖路径 ~130ms、抖动可达
// 2.5~3.7s)从 first_partial 关键路径上移除。协议固定 16kHz,spare 可互换。
mu sync.Mutex
spare *spareEntry
dialing bool
maintainOne sync.Once
}
type spareEntry struct {
sess *gummySession
born time.Time
}
// spareMaxAge spare 的可用年龄上限:DashScope 空闲 60s 断连(实测 60s 存活、
// 120s 报 Idle timeout),留余量在 45s 内取用、40s 定期换新。
const spareMaxAge = 45 * time.Second
func NewGummy(apiKey string) *GummyProvider {
return &GummyProvider{APIKey: apiKey, Model: "gummy-realtime-v1"}
}
@@ -31,6 +48,87 @@ func NewGummy(apiKey string) *GummyProvider {
func (g *GummyProvider) Name() string { return "gummy" }
func (g *GummyProvider) StartSession(ctx context.Context, cfg SessionConfig) (Session, error) {
g.maintainOne.Do(func() { go g.maintainSpare() })
if cfg.SampleRate == 16000 {
if s := g.takeSpare(); s != nil {
go g.replenish()
return s, nil
}
defer func() { go g.replenish() }()
}
return g.dialSession(ctx, cfg)
}
// takeSpare 取走当前 spare(若仍新鲜且存活);过期/已死的就地关闭丢弃。
func (g *GummyProvider) takeSpare() *gummySession {
g.mu.Lock()
sp := g.spare
g.spare = nil
g.mu.Unlock()
if sp == nil {
return nil
}
if time.Since(sp.born) < spareMaxAge && sp.sess.alive() {
slog.Info("gummy: session from spare", "task", sp.sess.taskID,
"age_ms", time.Since(sp.born).Milliseconds())
return sp.sess
}
_ = sp.sess.Close()
return nil
}
// replenish 异步补位一条 spare;已有 spare 或正在补位时空转返回。
func (g *GummyProvider) replenish() {
g.mu.Lock()
if g.dialing || g.spare != nil {
g.mu.Unlock()
return
}
g.dialing = true
g.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
sess, err := g.dialSession(ctx, SessionConfig{SampleRate: 16000, SessionID: "spare"})
cancel()
g.mu.Lock()
g.dialing = false
if err == nil {
if g.spare == nil {
g.spare = &spareEntry{sess: sess.(*gummySession), born: time.Now()}
g.mu.Unlock()
return
}
g.mu.Unlock()
_ = sess.Close() // 竞态下已有他人补位:丢弃多余会话
return
}
g.mu.Unlock()
slog.Warn("gummy: spare replenish failed", "err", err)
}
// maintainSpare 每 40s 换新 spare,避免超过 DashScope 60s 空闲上限后
// 取到已死会话、退化为冷启 dial。进程常驻,随 provider 生命周期运行。
func (g *GummyProvider) maintainSpare() {
t := time.NewTicker(40 * time.Second)
defer t.Stop()
for range t.C {
g.mu.Lock()
sp := g.spare
stale := sp != nil && time.Since(sp.born) >= spareMaxAge-5*time.Second
if stale {
g.spare = nil
}
g.mu.Unlock()
if stale {
_ = sp.sess.Close()
}
g.replenish()
}
}
// dialSession 建立一条全新的 DashScope 会话(原 StartSession 主体)。
func (g *GummyProvider) dialSession(ctx context.Context, cfg SessionConfig) (Session, error) {
header := map[string][]string{
"Authorization": {"bearer " + g.APIKey},
"X-DashScope-DataInspection": {"enable"},
@@ -109,6 +207,16 @@ type gummySession struct {
lastText string // 当前句已下发文本,用于切分 partial/final
}
// alive 会话底层连接是否仍然存活(readLoop 退出即 done 关闭)。
func (s *gummySession) alive() bool {
select {
case <-s.done:
return false
default:
return true
}
}
func (s *gummySession) SendAudio(pcm []byte) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()