fix: 诊断提速与稳定性修复(reasoning 2048→512、输出精简、上限16000→8000、loading提示、观测日志+配置错误可读)
This commit is contained in:
@@ -3,3 +3,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
# 运行日志与 E2E 测试产物
|
||||
*.log
|
||||
_e2e_*.js
|
||||
|
||||
@@ -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`
|
||||
|
||||
+16
-4
@@ -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=`<div class="bubble"><p>${escapeHtml(text)}</p></div>`;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=`<div class="avatar">AI</div><div class="bubble">${html}</div>`;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)=>`<div class="loading-row${i===0?' active':''}"><span class="loading-dot"></span>${escapeHtml(t)}</div>`).join('');
|
||||
return addAgent(`<div class="loading-card"><div class="loading-title"><span class="spinner" style="display:inline-block;vertical-align:-3px;margin-right:8px"></span>${escapeHtml(title)}</div>${list}</div>`);
|
||||
const list = (rows||['真实模型生成中,通常需要 1–2 分钟']).map((t,i)=>`<div class="loading-row${i===0?' active':''}"><span class="loading-dot"></span>${escapeHtml(t)}</div>`).join('');
|
||||
const el = addAgent(`<div class="loading-card"><div class="loading-title"><span class="spinner" style="display:inline-block;vertical-align:-3px;margin-right:8px"></span>${escapeHtml(title)}</div>${list}</div>`);
|
||||
// 真实模型生成可能超过 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(`<div class="loading-card"><div class="loading-title"><span class="spinner" style="display:inline-block;vertical-align:-3px;margin-right:8px"></span>正在读懂这篇文书…</div><div class="loading-row active"><span class="loading-dot"></span>正在结合题目理解全文</div><div class="loading-row"><span class="loading-dot"></span>正在确认每一段实际表达的内容</div><div class="loading-row"><span class="loading-dot"></span>正在整理需要与你确认的理解</div></div>`);
|
||||
// 统一走 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);
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
|
||||
+2
-1
@@ -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})。
|
||||
|
||||
+9
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user