feat: 对话层 Agent 化(对话底座 + 自研 skill)

把对话页从固定状态机换成 skill 驱动的 Agent:顾问说什么都原样发给 agent,
由 SKILL.md + 工具决定该聊还是该调工具。工作台(workbenchView)未改动。

后端
- llm.py:新增 stream_chat()(流式 + tools),并带 reasoning 端点自动降级
  (gemini-3.7-flash 拒绝 effort:none,首次 400 后锁定重试)
- agent.py:最小对话底座内核——skill 注入 + 工具循环 + SSE 事件
- tools.py:8 个工具包住既有能力(analyze/diagnose/scaffold/reference/
  translate/recheck/record_note/confirm),延迟 import main 复用已验证的
  endpoint 处理器与测试接缝,prompts.py/schemas.py/evidence.py 未改一行
- main.py:POST /api/chat/stream(SSE)

skill
- skills/hvr-rewrite/SKILL.md:分诊规则、首轮盘点、AI 味清单对齐
  blader/humanizer 的 A–E 分类(含强度校准与反误报)、诚实性硬约束

前端
- 流式正文 + 工具卡片 + 选项按钮;工具 payload 直接喂既有渲染函数
  (renderUnderstanding/renderDiagnosis/renderRecheck),没有第二套 UI
- 删除随 agent 化失效的 runDiagnose / applyUnderstandingCorrection 死代码

验证(真实模型,非 mock)
- 65 单测全绿(test_agent.py 30 + test_demo.py 35)
- 逐轮实跑:盘点 → 确认 → 诊断 → 复检,SSE 事件序列与 payload kind 均符合契约
- 前端 readSSE 用真实响应字节按 7/64/全量三种切块回放,事件序列一致
- 实测修掉两个只在真跑时暴露的问题:模型调完分析直接调确认工具导致正文为空
  (SKILL.md 补「正文先行」硬规则);伪标题 `**N. 标题**` 让列表判定失败、
  短横线漏成字面字符(前端改逐行分组渲染)

未验证:浏览器人工走查(无浏览器自动化环境),仅到「真实 HTTP + 真实字节回放」这一层。
This commit is contained in:
LuminousRuoxi
2026-09-11 10:25:12 +08:00
parent 493c7f6416
commit 94a26227c1
7 changed files with 2170 additions and 73 deletions
+153
View File
@@ -163,6 +163,8 @@ class LlmClient:
if not self.api_key:
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
self._transport = transport
# 端点是否强制开思考(gemini-3.7-flash 是)——首次 400 后锁定,见 stream_chat
self._reasoning_locked = False
# -- internal ----------------------------------------------------------
def _post(self, payload: dict[str, Any]) -> httpx.Response:
@@ -237,7 +239,158 @@ class LlmClient:
)
return message_text(body)
def _stream_post(self, payload: dict[str, Any]) -> Any:
"""流式 POST:返回已进入响应体的 stream context(调用方负责关闭)。
与 _post 分开的原因:httpx 的流式响应必须在 with 块内消费完,
不能像 _post 那样把 Response 交出去——连接会被提前关闭。"""
kwargs: dict[str, Any] = {"timeout": self.timeout}
if self._transport is not None:
kwargs["transport"] = self._transport
client = httpx.Client(trust_env=False, proxy=self.proxy or None, **kwargs)
return client, client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
# -- public ------------------------------------------------------------
def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
disable_reasoning: bool = True,
max_tokens: int | None = None,
) -> Any:
"""流式对话(对话层专用)。工具层仍走 complete_json。
存在的理由:对话层要「边想边说」+ 能调工具,而 complete_json 强制
``response_format=json_object`` 且一次性返回,两者不能共存——分层后
工具层保留已验证的重试兜底,对话层只负责流式与工具编排。
逐条 yielddict):
{"type": "text", "text": "..."} 正文增量
{"type": "thinking", "text": "..."} 思考增量(display 用,可为空)
{"type": "tool_calls", "tool_calls": [{"id", "name", "arguments"}]}
流结束时一次性给出(增量已合并)
{"type": "done", "finish_reason": "..."}
"""
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
# 对话层默认关掉思考:用户等的是第一句话,不是想清楚再开口;
# 真正需要深想的活都在工具里(工具层另有 512 预算)
# 2026-09-11 实测:google/gemini-3.7-flash 端点「强制思考」,传
# effort=none 直接 400「Reasoning is mandatory for this endpoint」。
# 锁一次后本客户端不再尝试关思考(只第一个请求付代价)。
if self._reasoning_locked:
disable_reasoning = False
payload["reasoning"] = reasoning_config(disable_reasoning)
return self._iter_stream(payload)
def _iter_stream(self, payload: dict[str, Any], _retry: bool = True) -> Any:
"""消费 SSE 流并归并增量。工具调用的 id/name/arguments 是分片下发的,
必须按 index 累积到流结束才能拼出完整参数(OpenAI 兼容协议)。"""
t0 = time.monotonic()
partial: dict[int, dict[str, str]] = {}
finish_reason = ""
client = None
try:
client, stream_ctx = self._stream_post(payload)
with stream_ctx as resp:
if resp.status_code != 200:
resp.read()
detail = _readable_error(resp)
logger.warning(
"llm stream http %s dt=%.1fs", resp.status_code, time.monotonic() - t0
)
# 该模型不支持关思考 → 锁定并原地重试一次(此时尚未 yield 任何
# 内容,重试不会造成重复输出)
if (
_retry
and resp.status_code == 400
and "mandatory" in detail.lower()
and payload.get("reasoning", {}).get("effort") == "none"
):
self._reasoning_locked = True
payload["reasoning"] = reasoning_config(False)
client.close()
yield from self._iter_stream(payload, _retry=False)
return
raise LLMError(
f"调用大模型失败(HTTP {resp.status_code}):{detail}"
)
for line in resp.iter_lines():
if not line:
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue # 上游偶发的心跳/注释行,跳过不影响正文
choices = chunk.get("choices") or []
if not choices:
continue
choice = choices[0]
delta = choice.get("delta") or {}
if choice.get("finish_reason"):
finish_reason = str(choice["finish_reason"])
text = _join_content_parts(delta.get("content"))
if text:
yield {"type": "text", "text": text}
think = _join_content_parts(
delta.get("reasoning") or delta.get("reasoning_content")
)
if think:
yield {"type": "thinking", "text": think}
for call in delta.get("tool_calls") or []:
idx = int(call.get("index") or 0)
slot = partial.setdefault(idx, {"id": "", "name": "", "arguments": ""})
if call.get("id"):
slot["id"] = str(call["id"])
fn = call.get("function") or {}
if fn.get("name"):
slot["name"] = str(fn["name"])
if fn.get("arguments"):
slot["arguments"] += str(fn["arguments"])
except LLMError:
raise
except (httpx.HTTPError, ValueError, TypeError) as exc:
logger.warning(
"llm stream error dt=%.1fs %s", time.monotonic() - t0, exc.__class__.__name__
)
if isinstance(exc, httpx.HTTPError):
raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc
raise LLMError(f"调用大模型失败(配置错误,请检查代理/地址设置):{exc}") from exc
finally:
if client is not None:
client.close()
logger.warning(
"llm stream ok model=%s dt=%.1fs tool_calls=%s finish=%s",
self.model, time.monotonic() - t0, len(partial), finish_reason,
)
if partial:
yield {
"type": "tool_calls",
"tool_calls": [partial[i] for i in sorted(partial)],
}
yield {"type": "done", "finish_reason": finish_reason or "stop"}
def complete_json(
self,
system: str,