From 2e050295683c5e731471e15c2649c3c67c8ffc2d Mon Sep 17 00:00:00 2001 From: ymq Date: Fri, 11 Sep 2026 15:09:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E4=BC=9A=E8=AF=9D=E5=81=A5?= =?UTF-8?q?=E5=A3=AE=E6=80=A7=E4=B8=89=E4=BF=AE=E2=80=94=E2=80=94=E2=91=A0?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=E9=97=A8=E7=A6=81(=5FWRAP=5FUP=5FRESERVE=3D3?= =?UTF-8?q?,=E8=BD=AE=E6=AC=A1=E5=B0=86=E5=B0=BD=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E6=94=B6=E5=B0=BE=E6=8C=87=E4=BB=A4,?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E9=95=BF=E4=BB=BB=E5=8A=A1=E6=92=9E=E7=A9=BF?= =?UTF-8?q?max=5Fturns=E9=9D=99=E9=BB=98=E9=80=80=E5=87=BA=E5=8F=AA?= =?UTF-8?q?=E7=BB=99=E4=B8=AD=E9=97=B4=E5=8F=A5);=E2=91=A1utility=E8=B0=83?= =?UTF-8?q?=E7=94=A8(=E5=8E=8B=E7=BC=A9=E6=91=98=E8=A6=81/=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E9=80=89=E6=8B=A9)=E6=98=BE=E5=BC=8F=E9=92=89model+?= =?UTF-8?q?=E7=9F=AD=E8=B6=85=E6=97=B660s=E5=BF=AB=E9=80=9F=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=B5=B0=E5=85=9C=E5=BA=95(=E6=A0=B9=E6=B2=BB?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E6=8A=96=E5=8A=A8=E7=AA=97=E5=8F=A3=E5=86=85?= =?UTF-8?q?=E8=BF=9E=E7=83=A715=E5=88=86=E9=92=9F=E5=8D=A1=E6=AD=BB?= =?UTF-8?q?=E6=95=B4=E8=BD=AE);=E2=91=A2=E4=B8=BB=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=BA=A7=E9=87=8D=E8=AF=95(=E7=9E=AC?= =?UTF-8?q?=E6=97=B6Timeout/ClientError=E9=87=8D=E8=AF=951=E6=AC=A1+?= =?UTF-8?q?=E9=80=8F=E5=87=BA=E8=BF=9B=E5=BA=A6,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=8D=95=E6=AC=A1=E5=A4=B1=E8=B4=A5=E5=8D=B3=E7=BB=88=E6=AD=A2?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=A2=E6=95=B4=E8=BD=AE=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/agent_loop_v2.py | 66 ++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index 0bcd9d9..7d1c5f6 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -87,6 +87,18 @@ class AgentExecutor: 4. 会话持久化 """ + # 收尾预留轮次(2026-09-11):轮次预算剩这么多轮时注入「立即收尾」指令, + # 强制 LLM 停止调工具、基于已有材料输出最终结论。根因见 run() 内 5-pre0 段注释 + #(v1 有 _FORCE_PRODUCE_HINT、v2 此前缺等价机制 → 长任务撞穿 max_turns 后 + # 静默退出,用户只拿到中间进度句)。 + _WRAP_UP_RESERVE = 3 + + # 辅助调用(压缩摘要/技能选择/意图分类)超时上限(2026-09-11)。 + # 这些是「可降级」调用:失败有兜底(摘要退化为截断文本、技能选择退化为目录层)。 + # 给短超时让它们快速失败走兜底,而不是占用端点默认 300s × 3 重试把整轮卡死 + #(实测上游抖动窗口内 utility 调用连烧 15 分钟才放弃,会话全程无响应)。 + _UTILITY_TIMEOUT = 60 + def __init__( self, config, # AgentConfig (from pipeline_core) @@ -253,6 +265,26 @@ class AgentExecutor: for turn in range(max_turns): self._turn_count = turn + 1 + # 5-pre0. 收尾门禁(2026-09-11 商机产线实测教训):轮次预算接近耗尽时 + # 注入「立即收尾」指令,强制 LLM 用已有材料给出最终结论。 + # 根因:v1 角色 agent 有 _FORCE_PRODUCE_HINT 打断探索循环,v2 会话 agent + # 此前没有等价机制——多工具长任务(如商机调研→功能点估算→成本→总结) + # 会跑满 max_turns 后静默退出,用户只看到中间进度句(「功能点已算出…」), + # 拿不到要求的总结。收尾轮不再允许调工具,只准产出最终回答。 + _wrap_turn = max_turns - self._WRAP_UP_RESERVE + if turn == _wrap_turn: + self._msgs.append({ + "role": "user", + "content": ( + "⚠️【收尾指令】你的对话轮次即将用尽(仅剩 %d 轮)。" + "立即停止调用任何工具,基于已经获取的材料直接给出**完整的最终回答**:" + "把用户要求的每一项都答到(数据/结论/建议/总结)," + "信息不全的部分如实标注「数据未覆盖」并给出你的专业判断," + "不要再做探索、核对或补数。现在就输出最终结论。" % (max_turns - turn) + ), + }) + logger.info(f"wrap-up hint injected at turn {turn+1}/{max_turns}") + # 5-pre. 子 agent 运行时钩子(2026-09-10):心跳 + steer 消费 + stop 检测。 # 后台委派的子 executor 带 _subagent_id,每轮 tool-loop 边界: # - heartbeat 刷新 meta.updated_at(stale 判活依据) @@ -284,11 +316,32 @@ class AgentExecutor: # 5b. LLM 调用(native function calling,返回 dict) # 模型不可用等配置错误必须真实报给用户(error 事件), # 绝不能让异常打断流——前端会停在"思考中..."干等。 - try: - resp = await self._call_llm() - except Exception as e: - logger.error(f"run: LLM 调用失败 turn={turn+1}: {e}") - yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n" + # 会话级重试(2026-09-11):上游瞬时抖动(TimeoutError/ClientError)时 + # 重试一次再放弃。实测上游存在分钟级抖动窗口(同模型同上下文直调成功、 + # 产线调用 TimeoutError 3 连败),单次失败即终止会话代价过高(整轮工作丢失)。 + # 重试前向用户透出进度,避免"卡死"观感。 + resp = None + _last_err = None + for _attempt in range(2): + try: + resp = await self._call_llm() + break + except Exception as e: + _last_err = e + _transient = any(k in type(e).__name__ or k in str(e) + for k in ("Timeout", "ClientError", "ServerDisconnected", + "ConnectionReset")) + if not _transient or _attempt >= 1: + break + logger.warning(f"run: LLM 瞬时失败 turn={turn+1} 重试: {e}") + yield json.dumps({ + "type": "progress", + "message": "模型上游瞬时超时,正在重试…\n", + }, ensure_ascii=False) + "\n" + await asyncio.sleep(3) + if resp is None: + logger.error(f"run: LLM 调用失败 turn={turn+1}: {_last_err}") + yield json.dumps({"type": "error", "message": str(_last_err)}, ensure_ascii=False) + "\n" return # 5c. 原生 function calling:优先处理 tool_calls @@ -664,6 +717,7 @@ class AgentExecutor: temperature=0, org_id=self.org_id, purpose='utility', + timeout=self._UTILITY_TIMEOUT, ) m = _re.search(r"\[[^\]]*\]", content or "") if m: @@ -2133,8 +2187,10 @@ class AgentExecutor: summary = await llm_call( f"请用3-5句话总结以下对话的关键信息:\n\n{text[:4000]}", temperature=0.1, + model=self.model_name, org_id=self.org_id, purpose='utility', + timeout=self._UTILITY_TIMEOUT, ) return summary[:500] except Exception: