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:
@@ -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`` 且一次性返回,两者不能共存——分层后
|
||||
工具层保留已验证的重试兜底,对话层只负责流式与工具编排。
|
||||
|
||||
逐条 yield(dict):
|
||||
{"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,
|
||||
|
||||
Reference in New Issue
Block a user