From ba65d586b112fc3579cc3d8ab44391ef0b179c89 Mon Sep 17 00:00:00 2001 From: ymq Date: Tue, 8 Sep 2026 11:58:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=AE=8C=E5=A4=87=E6=80=A7?= =?UTF-8?q?=E7=A1=AC=E9=97=A8=E7=A6=81(2026-09-08=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=AE=9A=E5=A4=BA=E4=B8=80C+=E4=BA=8CA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 意图识别归主循环LLM(system prompt语义引导),完备性硬门禁归代码。 - invoke_model入口check_completeness按能力契约校验必备输入: t2i/t2v/tts要描述,i2v/i2i要输入图,asr要音频,r2v要参考媒体 - 缺失返回QUESTION前缀,不调上游零费用(在llm_infer之前) - v1角色agent+v2会话agent四处工具循环QUESTION硬拦截转 ask_user/ask_question,禁回填靠LLM自觉(deepseek类会空参硬试烧钱) - 22例单测全PASS(齐备/缺失/字符串vs数组形态/未登记能力不误伤) --- pipeline_service/agent_loop.py | 16 +++ pipeline_service/agent_loop_v2.py | 22 ++++ pipeline_service/platform_model_tools.py | 123 ++++++++++++++++++++++- 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index 8396346..b9da388 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -582,6 +582,7 @@ __TOOLS__ 产出文档/章节/方案时,遇到需要插图的内容(架构图/拓扑图/流程图示意图/效果图/封面图等),用 invoke_model 调用平台生成模型产出**真实图片**: - 不确定有什么模型时先 list_platform_models(capability=t2i 文生图 / i2v 图生视频 / tts 语音等)。 - invoke_model 的 model 可留空(平台按任务自动匹配),task 写清画面内容(主体/风格/构图/文字要求)。 +- **完备性先于执行(铁律)**:生成类调用真花钱。调 invoke_model 前先自查该能力的必需输入是否齐备(文生图/视频要有画面描述;图生视频/图生图要有输入图;语音识别要有音频;缺媒体素材时先 ask_question 向人类要,拿到再调)。工具返回 QUESTION: 开头 = 平台门禁判定输入不完备、未发生调用,把追问原样转达给用户/按角色规则 ask_question,禁止自行编造素材或空参硬试。 - 成功后把返回的产物 URL **原样**以 markdown 图片语法嵌入正文:![图N 标题](URL)。禁止改写/自编 URL。 - **禁止用 ASCII 字符画/文本框线拼「示意图」代替真实配图**。平台无可用生成模型(invoke_model 返回 FAIL)时,在产出中如实标注「配图缺失:平台无可用文生图模型」,不伪造。 @@ -2391,6 +2392,15 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): result = await _load_skill_by_name(sor, project_id, role, org_id, params.get("name", ""), params.get("file_path") or None) else: result = await _exec_agent_tool(tool, params, space_dir, capability_ctx) + # 完备性/澄清门禁硬拦截(2026-09-08 一C+二A):QUESTION 前缀 = + # 平台判定缺必备输入、已停在花钱动作前。角色 agent 无人在旁, + # 转成 ask_question 冒泡给人类(任务挂起 waiting),禁止回填后 + # 靠 LLM 自觉(会空参硬试烧钱)。 + if isinstance(result, str) and result.startswith("QUESTION:"): + ask_question = result[len("QUESTION:"):].strip() + ask_form = None + logger.info(f"role_agent 门禁转提问: {tool} -> {ask_question[:80]}") + 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)}) @@ -2416,6 +2426,12 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): result = await _load_skill_by_name(sor, project_id, role, org_id, params.get('name', ''), params.get('file_path') or None) else: result = await _exec_agent_tool(tool, params, space_dir, capability_ctx) + # 完备性/澄清门禁硬拦截(同 native 路径,2026-09-08 一C+二A) + if isinstance(result, str) and result.startswith("QUESTION:"): + ask_question = result[len("QUESTION:"):].strip() + ask_form = None + logger.info(f"role_agent 门禁转提问(text): {tool} -> {ask_question[:80]}") + break if tool == 'write_file' and params.get('path'): written_files.append(os.path.join(space_dir, params['path'])) msgs.append({"role": "assistant", "content": raw}) diff --git a/pipeline_service/agent_loop_v2.py b/pipeline_service/agent_loop_v2.py index 28eab6a..3630082 100644 --- a/pipeline_service/agent_loop_v2.py +++ b/pipeline_service/agent_loop_v2.py @@ -275,6 +275,19 @@ class AgentExecutor: if not result.startswith("未知工具") and not result.startswith("ERROR"): self._tool_call_count += 1 + # 完备性/澄清门禁硬拦截(2026-09-08 用户定夺:一C+二A): + # 工具返回 QUESTION: 前缀 = 平台判定「缺完成任务的必备输入」, + # 已停在花钱动作之前(上游未调用、零费用)。必须把它当 ask_user + # 直接抛给用户等回答,禁止回填后靠 LLM 自觉转达(deepseek 类 + # 模型会无视、继续空参硬试烧钱)。ask_user 工具同走此前缀,语义统一。 + if isinstance(result, str) and result.startswith("QUESTION:"): + question = result[len("QUESTION:"):].strip() + yield json.dumps({ + "type": "ask_user", "message": question, + }, ensure_ascii=False) + "\n" + await self._save_turn(user_input, f"[提问] {question}") + return + yield json.dumps({ "type": "tool_result", "tool": tool_name, "result": result[:500], }, ensure_ascii=False) + "\n" @@ -341,6 +354,15 @@ class AgentExecutor: if not result.startswith("未知工具") and not result.startswith("ERROR"): self._tool_call_count += 1 + # 完备性/澄清门禁硬拦截(同 native 路径,2026-09-08 一C+二A) + if isinstance(result, str) and result.startswith("QUESTION:"): + question = result[len("QUESTION:"):].strip() + yield json.dumps({ + "type": "ask_user", "message": question, + }, ensure_ascii=False) + "\n" + await self._save_turn(user_input, f"[提问] {question}") + return + yield json.dumps({ "type": "tool_result", "tool": tool_name, "result": result[:500], diff --git a/pipeline_service/platform_model_tools.py b/pipeline_service/platform_model_tools.py index 5272c5d..156d1a1 100644 --- a/pipeline_service/platform_model_tools.py +++ b/pipeline_service/platform_model_tools.py @@ -26,6 +26,99 @@ _CAP_DESC = { "embedding": "向量化", "rerank": "重排序", } +# ───────────────────────────────────────────────────────────────── +# 完备性硬门禁(2026-09-08 用户定夺:一C+二A) +# +# 用户原则:「意图识别后还要能识别用户输入是否有完成任务的完备条件, +# 如果不够,必须询问得到,不能做不好还做,浪费钱财」。 +# +# 分工(一C):意图识别归主循环 LLM(system prompt 引导,零额外调用); +# **完备性硬门禁归代码**——放在贵工具入口、上游调用之前。理由:完备性是 +# 能力契约的确定性检查(该能力要求哪些输入、传没传),不是语义猜测, +# 代码判定可靠且不依赖模型自觉(deepseek 类模型无视 prompt 指令有实测前科)。 +# +# 范围(二A):只护花钱/不可逆动作(生成类调用真花钱)。闲聊/查询不设门禁。 +# +# 返回 QUESTION: 前缀 → agent 循环把它当 ask_user 抛给用户(见 +# agent_loop_v2 run loop / v1 的 ask 处理),不调上游、一分钱不花。 +# ───────────────────────────────────────────────────────────────── + +# 能力 → 必备输入契约(缺任一即不完备,必须先问用户要) +# 键名对应调用方 params 的三数组契约字段 / task 文本 +_CAP_REQUIRED = { + "t2i": ("task",), # 文生图:必须有画面描述 + "t2v": ("task",), # 文生视频:必须有画面描述 + "tts": ("task",), # 语音合成:必须有要念的文本 + "i2v": ("image_files",), # 图生视频:必须有输入图 + "i2i": ("image_files",), # 图生图:必须有输入图 + "asr": ("audio_files",), # 语音识别:必须有音频 + "r2v": ("any_media",), # 参考生视频:至少一类参考媒体 +} +_MEDIA_KEYS = ("image_files", "audio_files", "video_files") + +# 必备输入的中文显示名(追问话术里给用户看,不用内部字段名) +_REQ_LABEL = { + "image_files": "输入图片", + "audio_files": "输入音频", + "video_files": "参考视频", + "any_media": "参考素材(图片/视频/音频任一)", +} +# task 类必备输入按能力的显示名(文生图要「画面描述」,语音合成要「朗读文本」) +_TASK_LABEL = { + "t2i": "画面描述", "t2v": "画面描述", "i2v": "运动/画面变化描述", + "tts": "朗读文本", "asr": "识别需求说明", +} + +# 缺输入时对用户的追问话术(按能力,说清要什么、什么格式) +_CAP_ASK = { + "t2i": "生成图片需要先有画面描述。请说明:画什么主体、风格(写实/插画/商务示意图等)、" + "比例或尺寸、图中是否要有文字及内容。", + "t2v": "生成视频需要先有画面描述。请说明:画面内容、时长、分辨率、风格。", + "tts": "语音合成需要先有要朗读的文本。请提供文本内容,并说明音色/语速偏好(可选)。", + "i2v": "图生视频必须先提供输入图片(公网 URL 或上传)。另外请说明想要的运动/画面变化。", + "i2i": "图生图必须先提供输入图片(公网 URL 或上传)。另外请说明要怎么改(风格化/局部重绘等)。", + "asr": "语音识别必须先提供音频文件(公网 URL 或上传)。", + "r2v": "参考生视频必须先提供至少一类参考素材(图片/视频/音频,公网 URL 或上传)," + "并说明想要的画面内容与运动。", +} + + +def _biz_media(biz, key): + """业务参数里的媒体数组是否非空(三数组契约,值可为字符串或数组)。""" + v = biz.get(key) + if isinstance(v, (list, tuple)): + return any(str(x or "").strip() for x in v) + return bool(str(v or "").strip()) + + +def check_completeness(capability, task, biz): + """完备性门禁:该能力契约要求的输入是否齐备。 + + 返回 '' = 完备可执行;否则返回需要向用户追问的话术(QUESTION 内容)。 + 契约未登记的能力(如 embedding/新能力)不拦——宁可放行也别误伤, + 但生成类主流能力全部登记在册。 + """ + cap = (capability or "").strip().lower() + required = _CAP_REQUIRED.get(cap) + if not required: + return "" + missing = [] + for req in required: + if req == "task": + if not (task or "").strip(): + missing.append(_TASK_LABEL.get(cap, "画面/内容描述")) + elif req == "any_media": + if not any(_biz_media(biz or {}, k) for k in _MEDIA_KEYS): + missing.append(_REQ_LABEL["any_media"]) + else: + if not _biz_media(biz or {}, req): + missing.append(_REQ_LABEL.get(req, req)) + if missing: + ask = _CAP_ASK.get(cap, "该能力缺少必需输入,请补充:" + "、".join(missing)) + return ("调用「" + _CAP_DESC.get(cap, cap) + "」模型缺少必需输入(" + + "、".join(missing) + "),已停在调用前、未产生任何费用。" + ask) + return "" + async def tool_list_platform_models(params, org_id): """列出平台可用模型(本机构+平台owner机构,含能力类型与描述)。""" @@ -54,13 +147,29 @@ async def tool_list_platform_models(params, org_id): return "\n".join(lines) +async def _resolve_capability(model_name, org_id): + """按模型注册名反查能力类型(门禁需要知道该查哪份输入契约)。 + + 走 models_catalog(与推理链同一机构可见性),查不到返回 ''。 + """ + try: + from pipeline_llm.selection import models_catalog + models = await models_catalog(org_id or "0", capabilities=()) + for m in models: + if model_name in (m.get("name"), m.get("vendor_model_id")): + return (m.get("capability") or "").strip().lower() + except Exception as e: + logger.warning("_resolve_capability(%s) 失败: %s", model_name, e) + return "" + + async def tool_invoke_model(params, org_id, user_id=""): """调用平台模型完成生成类任务(文生图/视频/语音等非对话能力)。 流程:解析 task/model/capability/params → 未指定 model 时按 task 自动 选型(auto_select_model,候选=本机构+平台owner,capability 空=全能力) - → 组包 payload(messages=[user:task] + 业务参数平铺,媒体三数组契约由 - inference 层归一)→ llm_infer 统一推理端点 → 提取生成物 URL 返回。 + → **完备性硬门禁**(缺必需输入则 QUESTION 追问,不调上游不花钱) + → 组包 payload → llm_infer 统一推理端点 → 提取生成物 URL 返回。 失败返回真实可行动错误(禁静默粉饰)。 """ p = params or {} @@ -105,6 +214,16 @@ async def tool_invoke_model(params, org_id, user_id=""): except Exception as e: return "ERROR: 自动选型失败: " + str(e)[:300] + # ── 完备性硬门禁(2026-09-08 用户定夺:一C+二A)── + # 必须在 llm_infer 之前:缺输入直接 QUESTION 追问用户,上游一分钱不花。 + # capability 未显式给出时按选中模型反查(门禁需知道查哪份输入契约)。 + if not cap: + cap = await _resolve_capability(model, org_id) + gap = check_completeness(cap, task, biz) + if gap: + logger.info("invoke_model 完备性门禁拦截(%s): %s", cap or "?", gap[:80]) + return "QUESTION: " + gap + # 组包:生成类模型从 messages 末条取 prompt 文本(inference._last_prompt_text) payload = {"messages": [{"role": "user", "content": task}]} payload.update(biz)