fix: 流式渲染回合生命周期 + LLM 瞬断重试与 SSE body 兜底

问题一(webui 不是真流式):
- sendChat 的 finally 在 POST 结束(15s ackTimer abort)时就复位
  chatLoading,但 agent 生成窗口 15~190s,后续 SSE delta 全部走
  全量重建路径、停止按钮提前消失、用户误发重复消息。
- GUI app.js 完全没有 content_delta/reasoning_delta 监听器,
  只能等聚合帧一次性显示。

修复:三端统一回合生命周期——POST 只是触发,收尾由 SSE 驱动:
- dashboard/GUI 新增 endChatTurn/armTurnWatchdog;拿到同步兜底
  响应立即收尾,否则保持回合打开等 agent_output final / reset 帧 /
  120s watchdog 兜底
- GUI 补齐 delta 监听器;agent_output 聚合分支 += 改覆盖;
  reasoning 聚合帧改覆盖(多轮工具调用时旧逻辑会重复累加)
- agent_output 误杀分支(final 无 source 即 return 丢弃新输出)
  改为内容比较去重,多轮连发时新一轮回复不再被吞
- waiter reasoning_delta reset 从清空全部消息改为 sealLastAgent

问题二(三条只成功一条):
- handleChat 60s ctx 含排队时间,agent 串行处理下第 N 条必超时
  (实测第 3 条 62s 超时 504);放宽到 300s(客户端 abort 时立即取消)
- LLM 单 provider 瞬断无重试:process.go provider 循环内加同源
  重试(2 次、退避 2s),401/403 凭证错误与用户中断不重试
- llmsproxy auto 链在非流式请求下可能返回 SSE body(上游恢复后
  吐已生成的 chunk 流),非流式解析报 invalid character 'd' 丢掉
  整段回复;新增 parseOpenAICompatibleSSEBody 拼接为完整响应
- 顺带修 normalizeStreamToolCalls 分片续传 bug:name 不重发时
  argsRaw 被顶层 Arguments(nil) 覆盖丢失 function.arguments

验证:
- 连发 3 条 + 单条共 4 条全部成功(首条 190s 重试扛住瞬断)
- sse_body_test.go 锁定 SSE body 解析契约(content/usage/tool call 分片)
This commit is contained in:
JianFeeeee
2026-08-25 12:24:11 +08:00
parent a51079e7cd
commit cf7e5d0c38
7 changed files with 469 additions and 45 deletions

View File

@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
// 锁定契约:网关(llmsproxy auto 链等)在非流式请求下返回 SSE 流 body 时,
// 必须拼接为完整响应而不是报 "invalid character 'd'" 丢掉已生成的回复。
// 事故样本取自 2026-08-25 生产日志:上游恢复后吐出完整 chunk 流被非流式解析器丢弃。
func TestParseOpenAICompatibleSSEBody(t *testing.T) {
body := "data: {\"id\":\"chatcmpl-572\",\"object\":\"chat.completion.chunk\",\"created\":1787630289,\"model\":\"x-preview-f-free\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"你好\"},\"finish_reason\":null}]}\n" +
"\n" +
"data: {\"id\":\"chatcmpl-572\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\",世界\"},\"finish_reason\":null}]}\n" +
"\n" +
"data: {\"id\":\"chatcmpl-572\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}]}\n" +
"data: {\"id\":\"chatcmpl-572\",\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":7,\"total_tokens\":107}}\n" +
"data: [DONE]\n"
resp, ok := parseOpenAICompatibleSSEBody([]byte(body))
if !ok {
t.Fatal("expected SSE body to be recognized")
}
if resp.Content != "你好,世界" {
t.Errorf("content = %q, want %q", resp.Content, "你好,世界")
}
if resp.FinishReason != "stop" {
t.Errorf("finish_reason = %q, want stop", resp.FinishReason)
}
if resp.TokenUsage.Total != 107 || resp.TokenUsage.Prompt != 100 || resp.TokenUsage.Completion != 7 {
t.Errorf("usage = %+v, want prompt=100 completion=7 total=107", resp.TokenUsage)
}
}
func TestParseOpenAICompatibleSSEBodyRejectsPlainJSON(t *testing.T) {
plain := `{"choices":[{"message":{"content":"hi"}}]}`
if _, ok := parseOpenAICompatibleSSEBody([]byte(plain)); ok {
t.Fatal("plain JSON body must not be treated as SSE")
}
}
func TestParseOpenAICompatibleSSEBodyToolCallShards(t *testing.T) {
// 用 json.Marshal 构建测试数据,避免 Go 字面量转义错误
chunk1 := map[string]interface{}{
"choices": []map[string]interface{}{{
"index": 0,
"delta": map[string]interface{}{
"tool_calls": []map[string]interface{}{{
"index": 0,
"id": "call_1",
"type": "function",
"function": map[string]interface{}{
"name": "exec",
"arguments": `{"command":`,
},
}},
},
}},
}
chunk2 := map[string]interface{}{
"choices": []map[string]interface{}{{
"index": 0,
"delta": map[string]interface{}{
"tool_calls": []map[string]interface{}{{
"index": 0,
"function": map[string]interface{}{
"arguments": `"date"}`,
},
}},
},
}},
}
chunk3 := map[string]interface{}{
"choices": []map[string]interface{}{{
"index": 0,
"delta": map[string]interface{}{},
"finish_reason": "tool_calls",
}},
}
var sb strings.Builder
for _, c := range []map[string]interface{}{chunk1, chunk2, chunk3} {
b, _ := json.Marshal(c)
sb.WriteString("data: ")
sb.Write(b)
sb.WriteString("\n")
}
sb.WriteString("data: [DONE]\n")
t.Logf("SSE body:\n%s", sb.String())
resp, ok := parseOpenAICompatibleSSEBody([]byte(sb.String()))
if !ok {
t.Fatal("expected SSE body to be recognized")
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("got %d tool calls, want 1", len(resp.ToolCalls))
}
tc := resp.ToolCalls[0]
if tc.Name != "exec" || tc.ID != "call_1" {
t.Errorf("tool call name/id = %q/%q, want exec/call_1", tc.Name, tc.ID)
}
args := tc.RawArguments
if args != `{"command":"date"}` {
t.Errorf("raw args = %q", args)
}
if tc.Arguments["command"] != "date" {
t.Errorf("parsed args = %v, want command=date", tc.Arguments)
}
if resp.FinishReason != "tool_calls" {
t.Errorf("finish_reason = %q, want tool_calls", resp.FinishReason)
}
}
func TestParseOpenAICompatibleSSEBodyEmptyStream(t *testing.T) {
body := "data: \ndata: \n"
if resp, ok := parseOpenAICompatibleSSEBody([]byte(body)); ok && strings.TrimSpace(resp.Content) != "" {
t.Fatalf("empty stream should not parse into non-empty response")
}
}