diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index c4724d83..e759d19d 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -10,8 +10,8 @@ ## 0. 速读 -- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。 -- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。 +- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。 +- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。 - **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。 - **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。 - **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。 @@ -59,7 +59,7 @@ Step 3 最终方案展示 | 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | | 3 | 3 | `artifacts.py`(通用) | 无新 agent | -所有接口需 `Authorization: Bearer `。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:`headers_in=40s+`,前端表现为「Step 1 init 卡顿 / Step 2 init 永远不发 seat_ready」)。 +所有接口需 `Authorization: Bearer `。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。 --- @@ -499,7 +499,7 @@ async def _broadcast(client, base, headers, message, agent_threads, skip): (None, captured) — 流结束,captured 是所有 data: 行的字符串列表 ``` -日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查首 token 慢、代理导致 loopback 超时(见 `trust_env=False`)。 +日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。 `_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。 @@ -593,14 +593,21 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力 ## 9. 关键设计决策 -### 9.1 HTTP loopback + `trust_env=False` +### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback) -三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。 +三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。 -**每个 `httpx.AsyncClient(timeout=None, trust_env=False)` 都是必须的**: +统一入口:`multi_agent._make_loopback_client(request)`,返回: -- `timeout=None` — SSE 长连接需要 -- `trust_env=False` — 否则 httpx 会读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量,把 loopback `127.0.0.1` 也走代理。开发机常配的「校园网代理 / 公司代理」会把回环地址丢弃,导致单次请求 40+ 秒超时(日志典型症状:`headers_in=42.160s`) +```python +httpx.AsyncClient( + transport=httpx.ASGITransport(app=request.app), + base_url="http://loopback", # 占位 host,ASGITransport 不在意 + timeout=None, # SSE 长连接仍需 None +) +``` + +**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。 ### 9.2 协调智能体每次新建 @@ -1105,7 +1112,7 @@ curl -X POST http://127.0.0.1:8001/api/recommend/stream \ | Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) | | 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) | | Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 | -| Step 2 init 完全卡住 ~40 秒 | `httpx` 走了 `HTTP_PROXY` | 确认 `trust_env=False` 仍在;grep `headers_in=` 看时长 | +| Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 | | leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` | | dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 | | 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 | @@ -1122,7 +1129,7 @@ grep "dispatch failed" .runtime/logs/*.log grep "upstream-timing" .runtime/logs/*.log grep "leader-timing" .runtime/logs/*.log grep "special-timing" .runtime/logs/*.log -grep "headers_in=" .runtime/logs/*.log # 走代理症状 +grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms ``` --- diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 6b7de99d..fe1c781d 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -124,17 +124,17 @@ frontend-web/src/roundtable-planning/ [进入 Step 3] HighFidelityReport - consensusText ← lastLeaderContent - seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") - Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) - 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) -``` + consensusText ← lastLeaderContent + seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") + Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) + 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) + ``` ---- + --- -## 4. API 层 + ## 4. API 层 -### 4.1 `api/intent.ts` — Step 1 + ### 4.1 `api/intent.ts` — Step 1 | 函数 | 说明 | |------|------| diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 425b24db..5a83873c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -71,6 +71,7 @@ from app.gateway.routers.multi_agent import ( _find_trailing_ai_text, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -108,13 +109,10 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, """ base = _loopback_base(request) headers = _auth_headers(request) - # trust_env=False: this client only talks to the same-process gateway at - # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide - # on the dev machine) makes httpx route loopback calls through an upstream - # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per - # request before falling back. Symptom in logs: `headers_in=42.160s`. + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 try: - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + async with _make_loopback_client(request) as client: thread_id = await _create_thread(client, base, headers) except HTTPException: # 上游已用结构化方式失败,原样传给前端不需要二次包装。 @@ -165,11 +163,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index ce41e820..27527643 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -97,13 +97,12 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A if isinstance(root, httpx.ConnectError): target = str(root).strip().lower() - # loopback 失败 = Gateway 自己没起 / 代理拦回环;outbound 失败 = LLM 提供商不可达。 + # loopback 已改用 ASGITransport(进程内直调),不再经网络;ConnectError 几乎全部来自上游 LLM。 if "127.0.0.1" in target or "localhost" in target or "loopback" in target: code = "loopback_connect_failed" hint = ( - "本地 Gateway loopback 连接失败:确认后端进程在 8001 端口监听;" - "另检查 HTTP_PROXY / HTTPS_PROXY 是否把 127.0.0.1 也走了代理" - "(代码已显式 trust_env=False,理论上不该走代理)。" + "本地 Gateway ASGI loopback 失败:这种情况罕见,通常意味着 ASGI app " + "未正确注入到 request.app,或路由层抛出未捕获异常。请查后端日志栈。" ) else: code = "upstream_connect_failed" @@ -160,12 +159,44 @@ def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, A # --------------------------------------------------------------------------- # Loopback 辅助函数(Gateway → Gateway,同进程) +# +# 通过 httpx ASGITransport 直接调用本进程的 ASGI app,**不走 TCP / 不经过网络栈**: +# - 彻底规避「开发机 HTTP_PROXY 把 loopback 也走代理」导致的 40s 超时 +# - 不再需要 trust_env=False / 真实端口 / 真实 host +# - 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应一切照旧 +# +# 历史背景:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`, +# 见 git log 与 multi-agent-backend-dev.md §9.1。改用 ASGITransport 后已不需要 +# 防代理 workaround,但 _loopback_base 仍返回一个占位 base_url 以保持原有 helper +# 函数签名(_create_thread / _stream_upstream 等)零改动。 # --------------------------------------------------------------------------- +# ASGITransport 不在意 host,仅作 URL 拼接占位。 +_LOOPBACK_BASE_URL = "http://loopback" + + def _loopback_base(request: Request) -> str: - """当前请求的 scheme+host,用于 httpx 打回本机 Gateway。""" - return f"{request.url.scheme}://{request.url.netloc}" + """返回 loopback base URL 占位串。 + + ASGI transport 模式下不再用真实 scheme+host,但保留此函数以兼容 + `_create_thread / _stream_upstream` 等内部 helper 的 `base` 参数。 + """ + return _LOOPBACK_BASE_URL + + +def _make_loopback_client(request: Request) -> httpx.AsyncClient: + """构造一个走 ASGITransport 的 httpx.AsyncClient,**不走网络栈**。 + + 所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用都走这里, + 替代原先的 `httpx.AsyncClient(timeout=None, trust_env=False)` + 127.0.0.1。 + """ + transport = httpx.ASGITransport(app=request.app) + return httpx.AsyncClient( + transport=transport, + base_url=_LOOPBACK_BASE_URL, + timeout=None, + ) def _auth_headers(request: Request) -> dict[str, str]: @@ -431,11 +462,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: # Per-seat prepare task — same body as before, but we no longer # await them with asyncio.gather. Instead, asyncio.as_completed @@ -552,7 +581,7 @@ async def _stream_upstream( - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表, 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。 - 日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。 + 日志 ``[upstream-timing]``:排查首 token 慢;loopback 已走 ASGITransport,不会再有代理超时。 """ url = f"{base}/api/threads/{thread_id}/runs/stream" @@ -968,11 +997,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: if agent_type == "leader": gen = _leader_run( diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index 5035cb4c..45b0cb81 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -52,6 +52,7 @@ from app.gateway.routers.multi_agent import ( _create_thread, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -193,11 +194,9 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 thread_id = await _create_thread(client, base, headers)