From c04b6a0fd0cbd86f077910fe86cd7abac47b8d03 Mon Sep 17 00:00:00 2001 From: LuminousRuoxi <2494735589@qq.com> Date: Tue, 25 Aug 2026 13:21:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=8A=E6=96=AD=E6=8F=90=E9=80=9F?= =?UTF-8?q?=E4=B8=8E=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?reasoning=202048=E2=86=92512=E3=80=81=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E3=80=81=E4=B8=8A=E9=99=9016000=E2=86=928000?= =?UTF-8?q?=E3=80=81loading=E6=8F=90=E7=A4=BA=E3=80=81=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E6=97=A5=E5=BF=97+=E9=85=8D=E7=BD=AE=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++ ...e Rewrite Demo — 使用说明(给接手同事).md | 1 + index.html | 20 +++++++-- llm.py | 43 ++++++++++++++++--- prompts.py | 3 +- test_demo.py | 10 ++++- 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index b79f891..4c28e68 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.pyc .pytest_cache/ + +# 运行日志与 E2E 测试产物 +*.log +_e2e_*.js diff --git a/Human Voice Rewrite Demo — 使用说明(给接手同事).md b/Human Voice Rewrite Demo — 使用说明(给接手同事).md index 6a99178..1afe5d7 100644 --- a/Human Voice Rewrite Demo — 使用说明(给接手同事).md +++ b/Human Voice Rewrite Demo — 使用说明(给接手同事).md @@ -101,3 +101,4 @@ Add to PATH)。 - 只有顾问真正展开过的 Scaffold / Reference 才会进入复制检测 - 分析中(AI 初审 / 诊断 / 复检进行中)在对话页输入框输入并点发送:输入框内显示红色「AI 分析中,请稍候…」,已输入文本不清除(可继续输入,发送会被拦截,不会丢字) - 刷新位置规则:改写进行中刷新 → 回 Workbench(改写与进度保留);理解/诊断结果态刷新 → 回对话页(历史卡完整);复检结果已返回(pass 卡/返工卡在对话页)刷新 → 停留对话页;只有复检请求进行中刷新才回 Workbench(可重新提交复检) +- 分析耗时与超时提示(2026-08-25 同事实测诊断卡 5 分钟后的两轮优化):真实模型生成通常需要 1–2 分钟(上游排队时更久);loading 卡 45 秒后追加「仍在生成中,通常需要 1–3 分钟…」、2 分钟后追加「已超过 2 分钟…」——不是死机,是模型还在写。诊断侧已做三层控制:①输出精简(原来一次 6.6k tokens,现在约 2.5k);②思考预算 2048→512——DeepSeek 思考 token 生成速度约为正文的 1/10,预算给满会把时间全烧在想(实测 2048 = 309.8s、512 = 70–200s),当前实测诊断约 1–3.5 分钟,波动来自 OpenRouter 吞吐(12–80 tok/s),属外部因素;③模型打满输出上限时快速失败并自动重试一次,还不行会给出可读错误卡,稍后重试即可;嫌慢可换模型:启动前 `export HVR_LLM_MODEL=别的模型`(如 faster 或其它 deepseek 系列)。后端日志会打印每次模型调用的耗时与 token 用量(`llm ok model=... dt=... prompt=... completion=...`),排查慢链路直接看这个;思考预算调参入口在 `llm.py` 的 `reasoning_config` diff --git a/index.html b/index.html index 464be80..e0c5b59 100644 --- a/index.html +++ b/index.html @@ -85,7 +85,7 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im .rewrite-card{min-height:690px}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:500px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:vertical;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac} .submit-row{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 35%);padding:28px 24px 16px;display:flex;justify-content:flex-end;z-index:25;pointer-events:none}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px} .demo-panel{position:fixed;left:14px;bottom:14px;z-index:60}.demo-panel details{background:#fff;border:1px solid #dfe2ea;border-radius:10px;box-shadow:0 7px 20px rgba(23,27,42,.08);font-size:11px;color:#7d8495}.demo-panel summary{cursor:pointer;padding:7px 10px;font-weight:700;color:#666e80}.demo-panel .demo-body{padding:0 9px 9px;display:flex;gap:6px;flex-wrap:wrap;max-width:260px} -.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}@keyframes spin{to{transform:rotate(360deg)}} +.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}.loading-hint{color:#b9770b;font-size:12px;font-weight:600;margin-top:8px}@keyframes spin{to{transform:rotate(360deg)}} @media(max-width:1180px){.entry-shell{grid-template-columns:1fr 320px}.work-grid{gap:9px;padding-left:12px;padding-right:12px}.work-head{padding-left:14px;padding-right:14px}.pane-head{padding:0 12px}.progress-track{width:100px}.paragraph-switcher{max-width:min(280px,38vw)}} @media(max-width:900px){.work-grid{grid-template-columns:1fr}.sticky-pane{position:relative;top:auto;max-height:none}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.submit-row{position:relative;padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}} @media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:58px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}} @@ -276,8 +276,19 @@ function scrollChat(){setTimeout(()=>window.scrollTo({top:document.body.scrollHe function addUser(text, silent){const m=document.createElement('div');m.className='message user';m.innerHTML=`

${escapeHtml(text)}

`;chat.appendChild(m);if(!silent){m._logId=++chatLogSeq;chatLog.push({id:m._logId,t:'user',text});}scrollChat();} function addAgent(html, silent){const m=document.createElement('div');m.className='message';m.innerHTML=`
AI
${html}
`;chat.appendChild(m);if(!silent){m._logId=++chatLogSeq;chatLog.push({id:m._logId,t:'msg',html});}scrollChat();return m;} function addLoadingCard(title, rows){ - const list = (rows||['真实模型生成中,可能需要十几秒']).map((t,i)=>`
${escapeHtml(t)}
`).join(''); - return addAgent(`
${escapeHtml(title)}
${list}
`); + const list = (rows||['真实模型生成中,通常需要 1–2 分钟']).map((t,i)=>`
${escapeHtml(t)}
`).join(''); + const el = addAgent(`
${escapeHtml(title)}
${list}
`); + // 真实模型生成可能超过 1 分钟(同事实测 diagnose 卡 5 分钟误以为死机): + // 45s / 120s 时在卡内追加进度提示,避免"一直在转但没有反馈" + const hint = document.createElement('div'); + hint.className = 'loading-hint'; + hint.style.display = 'none'; + const card = el.querySelector('.loading-card'); + if(card) card.appendChild(hint); + const showHint = (msg) => { if(!document.body.contains(el)) return; hint.textContent = msg; hint.style.display = ''; }; + setTimeout(()=>showHint('仍在生成中,首次通常需要 1–3 分钟…'), 45000); + setTimeout(()=>showHint('已超过 2 分钟,如长时间无响应可稍后重试。'), 120000); + return el; } function removeEl(el){ // transient 卡片(loading/过渡提示/错误重试)从 DOM 移除时同步从历史快照删除,避免刷新后"复活" @@ -599,7 +610,8 @@ async function runAnalyze(){ ensureParaState(); track('human_voice_analysis_start', {paragraphs: paragraphs.length}); show('agentView'); chat.innerHTML=''; chatLog=[]; agentStage='loading'; - const loading = addAgent(`
正在读懂这篇文书…
正在结合题目理解全文
正在确认每一段实际表达的内容
正在整理需要与你确认的理解
`); + // 统一走 addLoadingCard:带 45s/120s 超时提示(真实模型生成可能超过 1 分钟) + const loading = addLoadingCard('正在读懂这篇文书…', ['正在结合题目理解全文','正在确认每一段实际表达的内容','正在整理需要与你确认的理解']); const rows = loading.querySelectorAll('.loading-row'); const t1 = setTimeout(()=>{rows[0].classList.remove('active');rows[1].classList.add('active')},650); const t2 = setTimeout(()=>{rows[1].classList.remove('active');rows[2].classList.add('active')},1300); diff --git a/llm.py b/llm.py index 01d29ba..6a4fd99 100644 --- a/llm.py +++ b/llm.py @@ -12,6 +12,7 @@ import json import logging import os import re +import time from typing import Any, Callable import httpx @@ -87,7 +88,13 @@ def reasoning_config(disable_reasoning: bool = False) -> dict[str, Any]: """OpenRouter rejects payloads that set both ``effort`` and ``max_tokens``.""" if disable_reasoning: return {"effort": "none", "exclude": True} - return {"max_tokens": 2048} + # 思考预算实测(2026-08-25 两次): + # 2048 → 单次 diagnose 309.8s,模型烧满全部思考 token;DeepSeek 思考 + # token 生成速度远慢于正文(约 1/10),5 分钟几乎全花在想。 + # 512 → 同文书同 prompt 实测 70.2s,输出完整合规(4 briefs / 5 patterns)。 + # 早期 2048→1024 曾因旧版冗长 prompt 引入 502(思考受限→输出不合规→校验 + # 失败);prompt 压紧凑后 512 可行,JSON 校验失败仍有 complete_json 重试兜底。 + return {"max_tokens": 512} def extract_json(content: str | None) -> dict[str, Any]: @@ -138,7 +145,10 @@ class LlmClient: model: str | None = None, proxy: str | None = None, timeout: float = 180.0, - max_tokens: int = 16000, + # 实测(2026-08-25):诊断正常输出 ~2.5k tokens(prompt 已压紧凑); + # 模型偶发啰嗦打满 16000 → 截断 → JSON 失败 → 重试又 258s,页面卡 5 分钟。 + # 8000 给正常输出 3 倍余量,打满时更快失败并走既有重试兜底。 + max_tokens: int = 8000, temperature: float = 0.3, transport: httpx.BaseTransport | None = None, ) -> None: @@ -196,13 +206,36 @@ class LlmClient: # diagnose 这类长 JSON 经常只返回空 content(页面上就是 502)。 # OpenRouter:effort 与 max_tokens 只能二选一。 payload["reasoning"] = reasoning_config(disable_reasoning) + # 观测:每次 LLM 调用都打耗时 + usage,慢链路(同事反馈 diagnose 卡 5 分钟)靠它定位 + t0 = time.monotonic() try: resp = self._post(payload) - except httpx.HTTPError as exc: # network / timeout - raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc + except (httpx.HTTPError, ValueError, TypeError) as exc: # 网络/超时;代理 URL 或配置错误(如 .env 解析出整行变量名) + dt = time.monotonic() - t0 + logger.warning("llm http-error dt=%.1fs %s", dt, exc.__class__.__name__) + if isinstance(exc, httpx.HTTPError): + raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc + # 2026-08-25 实测:代理 URL 带变量名前缀 → httpx 构造期 ValueError → 裸 500 + # (此前只捕 httpx.HTTPError)。配置类错误同样转可读 LLMError(PRD §11 失败兜底) + raise LLMError(f"调用大模型失败(配置错误,请检查代理/地址设置):{exc}") from exc + dt = time.monotonic() - t0 if resp.status_code != 200: + logger.warning("llm http %s dt=%.1fs retry-hint=%s", resp.status_code, dt, resp.headers.get("retry-after", "")) raise LLMError(f"调用大模型失败(HTTP {resp.status_code}):{_readable_error(resp)}") - return message_text(resp.json()) + try: + body = resp.json() + except Exception as exc: + raise LLMError("模型未返回内容(可能被截断或拒绝)") from exc + usage = body.get("usage") or {} + # uvicorn 默认只给 uvicorn.* logger 配 handler,root 的 info 会被吞; + # 观测行用 warning 级别保证进入 stderr 日志(慢链路定位依据) + logger.warning( + "llm ok model=%s dt=%.1fs prompt=%s completion=%s reasoning=%s total_time_ms=%s", + self.model, dt, + usage.get("prompt_tokens"), usage.get("completion_tokens"), + usage.get("reasoning_tokens"), usage.get("total_time"), + ) + return message_text(body) # -- public ------------------------------------------------------------ def complete_json( diff --git a/prompts.py b/prompts.py index 7344576..aed29f8 100644 --- a/prompts.py +++ b/prompts.py @@ -115,7 +115,8 @@ def build_diagnose_prompt( 要求: - 输出 Generative Writing Patterns,不输出 AI 概率 / AI Score / 是否 AI 写的判断。 -- 只输出当前文章实际命中的 Pattern;没有确凿原文证据不得硬凑。单段默认最多 1–3 个主要 Pattern / Annotation。 +- 只输出当前文章实际命中的 Pattern;没有确凿原文证据不得硬凑。全篇最多 5 个 Pattern(只保留最影响本人感的),单段最多 2 个主要 Pattern。 +- 输出必须紧凑(这是给顾问的人工工作流,不是写报告):overall_diagnosis ≤ 3 句;每个 Pattern 的文本字段(why_ai_like / human_impact / transformation_rule)各 ≤ 1 句、evidence 每项 ≤ 12 个词;annotations 每段最多 3 条,每条 observation / rewrite_action / why_ai_like 各 ≤ 1 句;paragraph_briefs 的 confirmed_meaning / rewrite_goal / ai_focus / context_hint 各 ≤ 2 句;must_preserve ≤ 3 项;scaffold 只给填空 / 思考顺序 / 一句不完整起笔。 - evidence 必须是该段原文中的原句或原短语,逐字引用;禁止改写原文当作证据;禁止输出原文不存在的片段。 - 每个 Pattern 都要说明为什么生成模型容易这么写(why_ai_like)与为什么影响本人感(human_impact)。 - category 只能是 rhetoric / repeat / growth / structure({categories})。 diff --git a/test_demo.py b/test_demo.py index 917a951..518ed65 100644 --- a/test_demo.py +++ b/test_demo.py @@ -113,10 +113,18 @@ def test_reasoning_config_does_not_set_effort_and_max_tokens(): retry = reasoning_config(True) assert not ("effort" in first and "max_tokens" in first) assert not ("effort" in retry and "max_tokens" in retry) - assert first == {"max_tokens": 2048} + assert first == {"max_tokens": 512} assert retry == {"effort": "none", "exclude": True} +def test_bad_proxy_url_raises_readable_llm_error(): + # 代理 URL 带变量名前缀(如 .env 解析出错)→ httpx 构造期 ValueError; + # 必须转可读 LLMError,不能裸 500(2026-08-25 实测事故,见 llm.py 注释) + c = LlmClient(api_key="test-key", proxy="PRODREAM_BACKEND_OPENROUTER_PROXY_URL=http://x:1@h:2") + with pytest.raises(LLMError, match="配置错误"): + c._complete("system", "user") + + def test_message_text_empty_mentions_finish_reason(): with pytest.raises(LLMError, match="finish_reason=length"): message_text(