feat:优化增量执行、工具调用容错与评测体系

This commit is contained in:
2026-10-11 04:40:25 -07:00
parent cd5a2b4d8f
commit dadd797391
22 changed files with 2198 additions and 578 deletions
+96 -38
View File
@@ -5,27 +5,31 @@
组件目录和物料在物料 MCP,各类任务的做法在 skill;系统提示只放每次都要用、别处放不下的原则
"""
from collections.abc import Sequence
from typing import Any, Literal, Self
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, wrap_model_call
from langchain.agents.middleware import ModelCallLimitMiddleware
from langchain.agents.structured_output import ToolStrategy
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
from loguru import logger
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, ValidationError, model_validator
from app.agent.data_tools import DATA_TOOLS
from app.agent.skills import skills_prompt
from app.agent.tool_args import normalize_tool_args
from app.agent.tools import (
ScreenAgentContext,
ScreenAgentState,
apply_actions,
get_nodes,
last_write_failed,
)
from app.clients.llm import get_llm
from app.clients.mcp import get_mcp_tools
from app.config import settings
from app.prompt.domain import INPUT_AS_DATA
from app.schemas.actions import Action, AddAction
from app.schemas.clarification import Clarification
SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,新增、修改、删除组件,或调整画布
@@ -45,13 +49,15 @@ SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,
class AgentReply(BaseModel):
kind: Literal["done", "clarify", "unable"] = Field(
description="done:最后一次 apply_actions 已成功,或请求不需要修改(例如只是提问);"
"clarify:需要用户回答才能继续;unable:无法完成"
description="done:要求的修改都已写入草稿,且最后一次 apply_actions 成功,草稿交付给用户;"
"或请求不需要修改(例如只是提问);clarify:需要用户回答才能继续;unable:无法完成。"
"clarify 和 unable 时草稿整个丢弃,用户看到的仍是原始大屏"
)
message: str = Field(
default="",
description="给用户看的一两句话。done:说明改了什么,用了默认值或示例数据时一并说明,没有修改时直接回答;"
"unable:说明原因,有可行的替代做法时一并给出;clarify:不用填",
description="给用户看的一两句话,不要提草稿、工具、参数这类内部说法。done:说明改了什么,用了默认值或示例数据时一并说明,没有修改时直接回答;"
"unable:草稿整个丢弃,用户的大屏和请求前一样,不要说已经删除、新增或修改了什么;"
"说明没能完成什么和原因,有可行的替代做法时一并给出;clarify:不用填",
)
clarification: Clarification | None = Field(
default=None, description="kind=clarify 时填写,其他情况不填"
@@ -64,39 +70,89 @@ class AgentReply(BaseModel):
return self
# 模型没有调用工具时最多纠正几次
NO_TOOL_CALL_RETRIES = 2
NO_TOOL_CALL_HINT = (
"每一轮都要调用工具。要结束时调用 AgentReply,给用户的话写在 message 里"
)
FINALIZE_RULES = """<finalize>
这次执行已经结束,不能再调用其他工具,也不能再修改大屏,只能调用 AgentReply 给出结论
按上面的对话和执行情况判断:用户要求的修改没有全部完成时不能回复 done
上面 apply_actions 返回的"执行成功""已删除"说的是这次的草稿,不是用户看到的大屏;回复 unable 时草稿整个丢弃,用户的大屏没有任何变化
</finalize>"""
@wrap_model_call
async def require_tool_call(request, handler):
"""ToolStrategy 要求每一轮都调用工具,结束时调用 AgentReply;模型偶尔只回文本(想直接回复用户)或空回复
def reached_call_limit(messages: Sequence[BaseMessage]) -> bool:
"""是不是因为调用次数用尽而结束:没结束的每次模型调用都带工具调用,数到上限就是被截断的"""
calls = sum(
isinstance(item, AIMessage) and bool(item.tool_calls) for item in messages
)
return calls >= settings.max_agent_steps
原样重试往往得到同样的结果,或者模型转而重复提交 apply_actions 直到调用次数用尽,
所以把它的回复和纠正说明加在下一次调用里;只用于这一次调用,不写进 agent 的消息
def _describe(action: Action) -> str:
if isinstance(action, AddAction):
return f"add {action.node.id}"
return f"{action.type} {getattr(action, 'target_id', '画布')}"
def _run_state(result: dict, limit_hit: bool, texts: list[str]) -> str:
"""收尾时告诉模型执行到了哪一步;对话里有每次工具结果,这里只给整体情况"""
lines = [
"调用次数已用完,执行被截断"
if limit_hit
else "上一轮没有调用 AgentReply 给出结论"
]
if actions := result.get("actions"):
described = ";".join(_describe(item) for item in actions[:40])
# 只写"已成功提交"时,模型回复 unable 也会说"已删除了某某",用户以为修改已经生效
lines.append(
f"草稿里的动作 {len(actions)} 个,还没有交付:{described};"
"只有回复 done 才会交付,回复 unable 或 clarify 时草稿整个丢弃,用户的大屏保持原样"
)
else:
lines.append("还没有成功提交过修改")
if last_write_failed(result.get("messages", [])):
lines.append("最后一次 apply_actions 失败,那一批没有写入草稿")
if texts:
lines.append(
f"上一轮只输出了文本,没有调用 AgentReply:{' '.join(texts)[:1000]}"
)
return "\n".join(lines)
async def finalize_reply(result: dict, *, limit_hit: bool) -> AgentReply | None:
"""运行正常结束却没有结论时补一次结论:调用次数用尽,或模型只回了文本、空回复
只调用一次,只绑定 AgentReply 并强制调用,没有能修改大屏的工具,不会再进入修改循环;
说明放在系统消息里,不追加用户消息,否则模型会把内部要求当成用户的话来回复
强制调用也可能给出不合法的参数,校验不过就返回 None,由节点按失败处理,不重试
"""
response = await handler(request)
for _ in range(NO_TOOL_CALL_RETRIES):
message = response.result[0] if response.result else None
if not isinstance(message, AIMessage) or message.tool_calls:
break
logger.warning(
f"模型没有调用工具,纠正后重新调用: {str(message.content)[:200]}"
)
# 空回复不放回去,部分接口不接受内容为空的助手消息
replied = [message] if message.content else []
request = request.override(
messages=[
*request.messages,
*replied,
HumanMessage(content=NO_TOOL_CALL_HINT),
]
)
response = await handler(request)
return response
messages = list(result["messages"])
# 末尾没有工具调用的助手消息(模型的文本、空回复、调用上限的说明)不放进对话:
# 以助手消息结尾时部分接口会把它当成续写的开头;模型的文本写进说明里
trailing: list[AIMessage] = []
while (
messages and isinstance(messages[-1], AIMessage) and not messages[-1].tool_calls
):
trailing.insert(0, messages.pop())
texts = (
[] if limit_hit else [str(item.content) for item in trailing if item.content]
)
run_state = _run_state(result, limit_hit, texts)
system = SystemMessage(
f"{SYSTEM_PROMPT}\n\n{FINALIZE_RULES}\n\n<run_state>\n{run_state}\n</run_state>"
)
model = get_llm().bind_tools([AgentReply], tool_choice="AgentReply")
response = await model.ainvoke(
[system, *messages], config={"run_name": "finalize_reply", "tags": ["finalize"]}
)
call = next(
(item for item in response.tool_calls if item["name"] == "AgentReply"), None
)
if call is None:
logger.warning(f"收尾没有调用 AgentReply: {str(response.content)[:200]}")
return None
try:
return AgentReply.model_validate(call["args"])
except ValidationError as error:
logger.warning(f"收尾给出的结论不合法: {error}")
return None
# 是否挂数据工具 → agent
@@ -120,9 +176,11 @@ async def get_screen_agent(with_data: bool = False):
response_format=ToolStrategy(AgentReply),
state_schema=ScreenAgentState,
context_schema=ScreenAgentContext,
# 超过上限时插入说明并结束;和模型只回文本、空回复一样没有结论,由节点调用 finalize_reply 收尾
# 模型没有调用工具时 agent 直接结束,不在循环里纠正:纠正说明会被当成用户的话,或让模型回到修改循环
middleware=[
require_tool_call,
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
# 列表、对象参数被写成 JSON 字符串时先解析回来,见 tool_args.py
normalize_tool_args,
ModelCallLimitMiddleware(
run_limit=settings.max_agent_steps, exit_behavior="end"
),