From 7b1f7e141b3e5deba717094c7f4ba7718e8fd433 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:14:48 -0400 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E6=B5=8B=E8=AF=95=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=A0=A1=E9=AA=8C=E6=A8=A1=E5=9E=8B=E7=A1=AE=E6=9C=89?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=EF=BC=8C=E5=B9=B6=E6=8C=89=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=94=B6=E5=8F=91=E6=A8=A1=E5=BC=8F=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试连接此前只判 err:请求通了但模型一个字不吐(思考烧光预算、正文被 安全策略吞掉、兼容层丢 content)照样报成功,而这种配置在会话里就是不 回话——正是「测试成功、对话不成功」的来源。 - TestConnection 返回回复文本,空回复直接判失败 - 回复回传前端,成功 toast 里展示,测试通过有据可查 - 测试连接改用该配置真实的收发模式:此前恒走流式(ConfigFrom 默认 Stream=true),选了非流式的配置测的是另一条通道;请求省略 streaming 时仍默认流式,老客户端不受影响 Closes #65 --- agent/provider.go | 22 +++++++++++++++++----- server/server.go | 25 +++++++++++++++++++++++-- web/src/app/(main)/system/llm/page.tsx | 18 ++++++++++++++++-- web/src/lib/api.ts | 5 ++++- web/src/lib/mock/handler.ts | 3 ++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/agent/provider.go b/agent/provider.go index ba12d5e..09b277b 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -357,11 +357,12 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) { } // TestConnection makes a minimal real completion to verify the provider/model/ -// endpoint/key actually work. Returns the round-trip latency. -func TestConnection(ctx context.Context, c Config) (time.Duration, error) { +// endpoint/key actually work. Returns the round-trip latency and the model's +// reply text. +func TestConnection(ctx context.Context, c Config) (time.Duration, string, error) { prov, err := c.NewProvider() if err != nil { - return 0, err + return 0, "", err } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -371,7 +372,7 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) { // 就撞到输出上限(finish=length)、被截断,连接测试虽仍算通(err=nil)但显示成 // "已中断/length/resume" 一团糟。给足预算让它把 OK 干净吐完(finish=stop)。 // EscalateMaxTokens 保持 false:不因截断而抬额重试,避免 resume 循环空烧。 - _, err = agentcore.Run(ctx, agentcore.Options{ + reply, err := agentcore.Run(ctx, agentcore.Options{ Provider: prov, SystemPrompt: []string{"你是连接测试。直接输出两个字符 OK 即可,不要思考、不要解释、不要别的。"}, PermissionMode: acperm.ModeBypass, @@ -379,5 +380,16 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) { MaxTokens: 8192, NonStreaming: !c.Stream, // 用该 profile 的真实收发模式做连接测试 }, "ping") - return time.Since(start), err + lat := time.Since(start) + if err != nil { + return lat, "", err + } + // err==nil 还不够:请求通了但模型一个字都不吐的情况真实存在(思考把预算烧光、 + // 正文被安全策略吞掉、兼容层把 content 丢了)。这种配置在会话里就是"不回话", + // 测试却报成功——正是本项要消除的落差。没有可见正文一律判失败。 + reply = strings.TrimSpace(reply) + if reply == "" { + return lat, "", fmt.Errorf("模型无回复内容(请求已通,但未返回任何文本)") + } + return lat, reply, nil } diff --git a/server/server.go b/server/server.go index dca9193..f7ab8c7 100644 --- a/server/server.go +++ b/server/server.go @@ -1337,6 +1337,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { ThinkingType string `json:"thinking_type"` ReasoningEffort string `json:"reasoning_effort"` ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key + Streaming *bool `json:"streaming"` // 省略=流式,与保存 profile 时同一套默认 } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -1347,6 +1348,11 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { // reasoning_effort/thinking field fails the test too (no false "test ok, run 400"). cfg.ThinkingType = req.ThinkingType cfg.ReasoningEffort = req.ReasoningEffort + // 同理,收发模式也照该 profile 的选择来:只支持其中一种通道的端点必须在这里就 + // 暴露,而不是等会话里才发现"测试通过的配置根本跑不动"。 + if req.Streaming != nil { + cfg.Stream = *req.Streaming + } // API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。 // 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。 if cfg.APIKey == "" && req.ProfileID != nil { @@ -1363,12 +1369,27 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"}) return } - lat, err := agent.TestConnection(r.Context(), cfg) + lat, reply, err := agent.TestConnection(r.Context(), cfg) if err != nil { writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) return } - writeJSON(w, 200, map[string]any{"ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model}) + // 回传模型实际回复,让"测试通过"有据可查:看得见它确实说了话,而不只是 HTTP 200。 + writeJSON(w, 200, map[string]any{ + "ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model, "reply": truncateReply(reply), + }) +} + +// truncateReply clips a connection-test reply for display. A model told to answer +// "OK" can still ramble (or think out loud); the UI only needs enough to show it +// really said something. Rune-based so multibyte text never splits mid-character. +func truncateReply(s string) string { + const max = 200 + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" } type createTaskReq struct { diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 3a3e5af..7065a29 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -369,8 +369,22 @@ function ProfileSheet({ try { // 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败, // 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。 - const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId); - if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`); + const r = await api.testLLM( + format, + model, + baseUrl, + apiKey, + proxy, + toStore(thinkingType), + toStore(effort), + profileId, + streaming, + ); + // 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。 + if (r.ok) + toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`, { + description: r.reply ? `回复:${r.reply}` : undefined, + }); else toast.error(`连接失败:${r.error ?? "未知"}`); } catch (e) { toast.error(`测试出错:${(e as Error).message}`); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6a0232e..03ce937 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -646,8 +646,10 @@ export const api = { thinking_type = "", reasoning_effort = "", profile_id?: number, + streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里 ) => - post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", { + // reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败 + post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", { provider, model, base_url, @@ -656,6 +658,7 @@ export const api = { thinking_type, reasoning_effort, profile_id, + streaming, }), llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)), saveLLMProfile: (p: { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 9e69c14..dd13347 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -1155,7 +1155,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re } if (path === "/llm" && m === "GET") return D.llmConfig; if (path === "/llm" && m === "POST") return { ok: true }; - if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") }; + if (path === "/llm/test") + return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8"), reply: "OK" }; if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles }; if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 }; if (path === "/llm/profiles/active") return { ok: true };