From 1faa1c1d2f8b6db7819b840849401fd5b7299878 Mon Sep 17 00:00:00 2001 From: ymq Date: Fri, 11 Sep 2026 13:46:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=B7=A5=E5=85=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=9B=9E=E5=A1=AB=E4=B8=8A=E9=99=90=E2=80=94=E2=80=94?= =?UTF-8?q?result=5Fcap.py(12000=E5=AD=97=E7=AC=A6=E9=BB=98=E8=AE=A4+param?= =?UTF-8?q?s=E5=8F=AF=E8=B0=83+=E6=98=BE=E5=BC=8F=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E5=91=8A=E7=9F=A5+=E7=BC=A9=E5=B0=8F=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E6=8C=87=E5=BC=95);v2=E4=B8=A4=E5=A4=84/v1=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E7=82=B9=E5=85=A8=E6=8E=A5;=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=E5=95=86=E6=9C=BA=E4=BA=A7=E7=BA=BF=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?:8=E6=AC=A1=E6=95=B0=E6=8D=AE=E5=B7=A5=E5=85=B7=E5=85=A8?= =?UTF-8?q?=E6=96=87=E5=9B=9E=E5=A1=AB~78K=20token=E8=B6=85context=5Flimit?= =?UTF-8?q?=E2=86=92=E5=8E=8B=E7=BC=A9=E6=91=98=E8=A6=81=E8=87=AA=E8=BA=AB?= =?UTF-8?q?=E8=B6=85=E6=97=B63=E8=BF=9E=E8=B4=A5=E2=86=92=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=9B=B6=E4=BA=A7=E5=87=BA(=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C:=E5=90=8C=E6=A8=A1=E5=9E=8B=E5=B0=8F?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=873.4s=E6=AD=A3=E5=B8=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_service/agent_loop.py | 8 ++- pipeline_service/agent_loop_v2.py | 14 ++++- pipeline_service/result_cap.py | 93 +++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 pipeline_service/result_cap.py diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index 95902be..e66cd16 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -2520,7 +2520,13 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): break if tool == "write_file" and params.get("path"): written_files.append(os.path.join(space_dir, params["path"])) - msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": str(result)}) + # 回填上限(2026-09-11,与 v2 同款):数据类工具全文回填会撑爆上下文 + # → 压缩摘要自身超时 → 任务失败。截断显式告知 + 缩小范围指引。 + from .result_cap import cap_tool_result, resolve_max_chars + _capped, _trunc = cap_tool_result(result, await resolve_max_chars(sor)) + if _trunc: + logger.info(f"role_agent tool_result truncated: {tool}") + msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": _capped}) logger.info(f"role_agent tool_call: {tool} -> {str(result)[:100]}") if deliverable or ask_question: break diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index 1d348f8..0bcd9d9 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -236,7 +236,12 @@ class AgentExecutor: if not result.startswith("未知工具") and not result.startswith("ERROR"): self._tool_call_count += 1 yield json.dumps({"type": "tool_result", "tool": pending_tool, "result": result[:500]}, ensure_ascii=False) + "\n" - self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": str(result)}) + # 回填上限(2026-09-11):工具结果全文回填会撑爆上下文(商机产线实测 + # 8 次数据工具 ~78K token → 压缩摘要自身超时 → 会话零产出)。 + # 截断显式告知 + 给缩小范围指引,让 LLM 自己改策略。 + from .result_cap import cap_tool_result, resolve_max_chars + _capped, _ = cap_tool_result(result, await resolve_max_chars()) + self._msgs.append({"role": "tool", "tool_call_id": fake_id, "content": _capped}) # 不 return,继续进入 Tool Loop,让 LLM 基于工具结果继续后续步骤 # Step 5: Tool Loop @@ -339,10 +344,15 @@ class AgentExecutor: }, ensure_ascii=False) + "\n" # 原生回填:role=tool + tool_call_id + # 回填上限:防数据类工具全文回填撑爆上下文(见 result_cap 模块 docstring) + from .result_cap import cap_tool_result, resolve_max_chars + _capped, _trunc = cap_tool_result(result, await resolve_max_chars()) + if _trunc: + logger.info(f"tool_result truncated before refill: {tool_name}") self._msgs.append({ "role": "tool", "tool_call_id": tc.get("id", "") if isinstance(tc, dict) else "", - "content": str(result), + "content": _capped, }) continue diff --git a/pipeline_service/result_cap.py b/pipeline_service/result_cap.py new file mode 100644 index 0000000..5722304 --- /dev/null +++ b/pipeline_service/result_cap.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""工具结果回填上限(通用,v1 角色 agent + v2 会话 agent 共用)。 + +问题根因(2026-09-11 商机产线实测会话失败): +工具结果回填给模型时是 `str(result)` **全文不截断**(仅 UI 显示截到 500 字), +数据类工具一次返回 300 条原始记录 ≈ 2 万字符 ≈ 1 万 token;agent 连调 8 次 +→ 上下文 ~78K token 超过 context_limit(64K) → 压缩摘要调用自身也超时(重试 3 次 +全败)→ 整轮会话失败、零产出。对照实验:同模型小上下文 3.4s 正常返回, +证明不是上游故障而是上下文体积。 + +修法:回填前按字符上限截断,**显式告知**(沿用 file_read.py 的「绝不静默截断」 +铁律)——告诉模型全文多大、只给了多少、以及怎么缩小范围(减小 limit / +改用聚合排名类工具),让它自己改策略而不是反复拉全量。 + +上限走 appbase params 表(tool_result_max_chars)+ 常量兜底,禁硬编码。 +""" + +import logging +from typing import Optional + +logger = logging.getLogger("pipeline.result_cap") + +# 兜底上限(params 表无配置时用)。12000 字符 ≈ 6000 token: +# 够放一份结构化聚合结果或几十条明细,放 300 条原始记录则会被截断并提示。 +DEFAULT_MAX_CHARS = 12000 + +_PARAM_KEY = "tool_result_max_chars" +# 进程内缓存(None = 未解析,惰性读 params;invalidate_cache() 清除) +_cached_max: Optional[int] = None + + +async def resolve_max_chars(sor=None): + """取回填上限(params 表优先,失败/未配置用常量兜底,进程内缓存)。 + + sor=None 时自开短连接读取(调用点如 agent_loop_v2.run() 的工具回填处 + 没有现成 DB context;结果缓存后不再重复开连接)。读失败一律兜底常量, + 绝不因为取参数失败而打断 agent 主循环。 + """ + global _cached_max + if _cached_max is not None: + return _cached_max + val = DEFAULT_MAX_CHARS + try: + from .workspace import get_param + if sor is not None: + raw = await get_param(sor, _PARAM_KEY, "") + else: + from sqlor.dbpools import DBPools + db = DBPools() + if not db.databases: + from appPublic.jsonConfig import getConfig + cfg = getConfig() + if cfg and cfg.databases: + db.databases = cfg.databases + async with db.sqlorContext("pipeline") as _sor: + raw = await get_param(_sor, _PARAM_KEY, "") + await _sor.sqlExe("COMMIT", {}) + if raw not in (None, ""): + val = max(1000, int(float(raw))) + except Exception as e: + logger.debug("resolve_max_chars fallback to default: %s", str(e)[:120]) + val = DEFAULT_MAX_CHARS + _cached_max = val + return val + + +def cap_tool_result(result, max_chars=None): + """截断工具结果(同步;max_chars=None 用常量兜底)。 + + 返回 (text, truncated)。截断时末尾附显式告知 + 可行动的缩小范围指引。 + 非字符串结果先 str() 归一(dict/list 也走同一路径)。 + """ + text = result if isinstance(result, str) else str(result) + limit = int(max_chars) if max_chars else DEFAULT_MAX_CHARS + total = len(text) + if total <= limit: + return text, False + notice = ( + "\n\n⚠️【结果过长已截断】全文 %d 字符,仅回填前 %d 字符(上限可用 params.%s 调整)。\n" + "不要重复调用同一工具拉全量——请缩小范围后重试:\n" + "- 减小 limit(如 limit=10~20)或缩小 days/时间窗口;\n" + "- 优先用聚合/排名类工具(如 hot_demands/hot_software/category 汇总)" + "直接拿统计值,而不是拉明细自己数;\n" + "- 明细需要落盘时用 write_file 写到项目工作空间,不要全量读进对话。" + % (total, limit, _PARAM_KEY) + ) + return text[:limit] + notice, True + + +def invalidate_cache(): + """params 变更后让缓存失效(部署/调参后无需重启进程)。""" + global _cached_max + _cached_max = None