feat:优化执行步骤
This commit is contained in:
@@ -14,7 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.skills import skill_middleware
|
||||
from app.agent.skills import skills_prompt
|
||||
from app.agent.tools import (
|
||||
ScreenAgentContext,
|
||||
ScreenAgentState,
|
||||
@@ -37,6 +37,7 @@ SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,
|
||||
<clarify_rules>
|
||||
默认先执行:缺少的细节(位置、尺寸、标题、样式等)用合理的默认值补齐
|
||||
只有无法确定用户指的是哪个组件,或看不出用户要做什么时才追问;澄清记录里已经回答过的不再问
|
||||
用户要改的组件在大屏上不存在时,说明这一点并给出可行的替代做法(例如新增一个),不要让用户去选中或指出组件
|
||||
只删除用户明确要求删除的组件;不确定删哪个时追问,不要猜
|
||||
</clarify_rules>"""
|
||||
|
||||
@@ -107,13 +108,12 @@ async def get_screen_agent():
|
||||
_agent = create_agent(
|
||||
model=get_llm(),
|
||||
tools=[get_nodes, apply_actions, *await get_mcp_tools()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
system_prompt=f"{SYSTEM_PROMPT}\n\n{skills_prompt()}",
|
||||
# 用工具调用给出结论:试过原生结构化输出(ProviderStrategy),歧义时模型更常直接执行而不追问,评测多删了组件
|
||||
response_format=ToolStrategy(AgentReply),
|
||||
state_schema=ScreenAgentState,
|
||||
context_schema=ScreenAgentContext,
|
||||
middleware=[
|
||||
*skill_middleware(),
|
||||
require_tool_call,
|
||||
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
|
||||
ModelCallLimitMiddleware(
|
||||
|
||||
+23
-34
@@ -1,44 +1,33 @@
|
||||
"""skill 渐进式加载:目录追加到系统提示,正文和附件由模型用 read_file 按需读取
|
||||
"""skill 正文直接写进系统提示
|
||||
|
||||
加载、解析和校验都由 Deep Agents 的 SkillsMiddleware 完成,这里只有配置
|
||||
skill 总共几 KB,每次调用都会命中提示缓存;如果让模型自己 read_file,每个请求要多 2 次串行调用,
|
||||
每次几秒,比多带这几 KB 贵得多。需要附件(references/)的 skill 出现后,再改回按需读取
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents.backends.filesystem import FilesystemBackend
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from deepagents.middleware.skills import SkillsMiddleware
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
SKILLS_ROOT = Path(__file__).resolve().parents[2] / "skills"
|
||||
|
||||
# 必须保留三个占位符;目录每一行的格式由库固定
|
||||
SKILLS_PROMPT = """<skills>
|
||||
以下 skill 提供特定任务的做法。{skills_locations}{skills_load_warnings}
|
||||
|
||||
{skills_list}
|
||||
|
||||
使用方式:
|
||||
1. 任务符合某个 skill 的描述时,先用 read_file 读取它的 SKILL.md 再动手,传 limit=1000(默认只读 100 行)
|
||||
2. 正文中引用的文件写的是相对路径,拼在该 skill 的目录后再读取,例如 chart-replace 正文里的 references/a.md 要读 /chart-replace/references/a.md
|
||||
3. 一个请求可以用到多个 skill
|
||||
</skills>"""
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
||||
_, header, body = text.split("---", 2)
|
||||
meta = {}
|
||||
for line in header.strip().splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta, body.strip()
|
||||
|
||||
|
||||
def skill_middleware(root: Path = SKILLS_ROOT) -> list[AgentMiddleware]:
|
||||
# virtual_mode:路径以 skills/ 为根,拦截 ..、~ 和根目录以外的路径
|
||||
backend = FilesystemBackend(root_dir=root, virtual_mode=True)
|
||||
return [
|
||||
FilesystemMiddleware(
|
||||
backend=backend,
|
||||
# 只开放读取,不提供写入、搜索和执行
|
||||
tools=["read_file"],
|
||||
# 关闭大结果写盘,否则会往 skills/ 里写文件
|
||||
tool_token_limit_before_evict=None,
|
||||
human_message_token_limit_before_evict=None,
|
||||
),
|
||||
# 用元组写明来源标签,裸路径 "/" 的标签会变成 Unnamed
|
||||
SkillsMiddleware(
|
||||
backend=backend, sources=[("/", "大屏")], system_prompt=SKILLS_PROMPT
|
||||
),
|
||||
]
|
||||
def skills_prompt(root: Path = SKILLS_ROOT) -> str:
|
||||
"""每个 skill 一段:description 说明什么时候用,正文是做法;任务符合描述时按正文做,一个请求可以用到多个"""
|
||||
sections = []
|
||||
for path in sorted(root.glob("*/SKILL.md")):
|
||||
meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
|
||||
sections.append(
|
||||
f'<skill name="{meta["name"]}">\n适用:{meta["description"]}\n\n{body}\n</skill>'
|
||||
)
|
||||
return (
|
||||
"<skills>\n以下 skill 提供特定任务的做法,任务符合“适用”时按正文做,一个请求可以用到多个\n\n"
|
||||
+ "\n\n".join(sections)
|
||||
+ "\n</skills>"
|
||||
)
|
||||
|
||||
+64
-7
@@ -13,7 +13,7 @@ from langgraph.types import Command
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions.batch import BatchError, run_batch
|
||||
from app.schemas.actions import Action, AddAction
|
||||
from app.schemas.actions import Action, AddAction, AgentAction
|
||||
from app.schemas.component_resource import ComponentCatalog
|
||||
from app.schemas.screen import Screen
|
||||
|
||||
@@ -51,21 +51,55 @@ def get_nodes(node_ids: list[str], runtime: ToolRuntime[ScreenAgentContext]) ->
|
||||
}
|
||||
|
||||
|
||||
def _result_text(actions: list[Action]) -> str:
|
||||
def _result_text(
|
||||
before: Screen,
|
||||
after: Screen,
|
||||
actions: list[Action],
|
||||
undeclared_sample: frozenset[str] = frozenset(),
|
||||
) -> str:
|
||||
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
|
||||
|
||||
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正,
|
||||
不用靠重复提交来试;本次动过布局或新增的组件前面标 *,已删除的单独列出
|
||||
"""
|
||||
lines = [f"执行成功,共 {len(actions)} 个动作已生效"]
|
||||
if sample := [
|
||||
sample = [
|
||||
action.node.id
|
||||
for action in actions
|
||||
if isinstance(action, AddAction) and action.sample_data
|
||||
]
|
||||
# 模型没写 sample_data 时是后端按物料猜的;说明这一点和怎么改,否则模型只看到“用了示例数据”,
|
||||
# 以为是自己的数据没生效,会反复提交同样的内容
|
||||
if guessed := [item for item in sample if item in undeclared_sample]:
|
||||
lines.append(
|
||||
f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};"
|
||||
"如果这些组件展示的数据都来自大屏上已有的组件,把 sample_data 设为 false 重新提交整批,否则不用处理"
|
||||
)
|
||||
if declared := [item for item in sample if item not in undeclared_sample]:
|
||||
lines.append(f"标为示例数据的新增组件:{'、'.join(declared)}")
|
||||
|
||||
old_layouts = {node.id: node.layout for node in before.nodes}
|
||||
lines.append(
|
||||
f"执行后画布 {after.canvas.width:g}x{after.canvas.height:g},组件布局(* 为本次改动):"
|
||||
)
|
||||
for node in after.nodes:
|
||||
layout = node.layout
|
||||
mark = "*" if old_layouts.get(node.id) != layout else " "
|
||||
lines.append(
|
||||
f"{mark} {node.id} {node.type} "
|
||||
f"x={layout.x:g} y={layout.y:g} width={layout.width:g} height={layout.height:g}"
|
||||
)
|
||||
if removed := [
|
||||
item for item in old_layouts if item not in {n.id for n in after.nodes}
|
||||
]:
|
||||
lines.append(f"使用物料示例数据的新增组件:{'、'.join(sample)}")
|
||||
lines.append(f"已删除:{'、'.join(removed)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@tool
|
||||
def apply_actions(
|
||||
actions: Annotated[
|
||||
list[Action],
|
||||
list[AgentAction],
|
||||
# 空的一批会清掉之前成功的结果;参数校验失败不改状态,之前的结果保留
|
||||
Field(min_length=1, description="至少一个动作;不需要修改时不要调用,直接回复"),
|
||||
],
|
||||
@@ -74,10 +108,11 @@ def apply_actions(
|
||||
"""提交本次要对大屏做的全部修改
|
||||
|
||||
每次调用都是完整的一批,从原始大屏开始按顺序模拟执行,不在上一次调用的结果上累加
|
||||
成功时返回执行后的画布和各组件布局,用它核对结果,不对再重新提交整批
|
||||
失败时整批不生效,返回带动作序号的错误,修正后重新提交整批;以最后一次调用的结果为准
|
||||
"""
|
||||
try:
|
||||
completed, _ = run_batch(
|
||||
completed, after = run_batch(
|
||||
runtime.context.screen, runtime.context.catalog, actions
|
||||
)
|
||||
except BatchError as error:
|
||||
@@ -93,12 +128,34 @@ def apply_actions(
|
||||
],
|
||||
}
|
||||
)
|
||||
# 和上一次成功的提交完全一样:结果早已生效,再提交不会有变化,直接告诉模型结束,不再空转
|
||||
if completed == runtime.state.get("actions"):
|
||||
return Command(
|
||||
update={
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
"这一批的结果和上一次提交完全相同,已经生效,不要重复提交;"
|
||||
"任务完成就调用 AgentReply 回复用户,没完成就改成不同的内容",
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
undeclared = frozenset(
|
||||
action.node.id
|
||||
for action in actions
|
||||
if isinstance(action, AddAction)
|
||||
and "sample_data" not in action.model_fields_set
|
||||
)
|
||||
return Command(
|
||||
update={
|
||||
"actions": completed,
|
||||
"apply_error": None,
|
||||
"messages": [
|
||||
ToolMessage(_result_text(completed), tool_call_id=runtime.tool_call_id)
|
||||
ToolMessage(
|
||||
_result_text(runtime.context.screen, after, completed, undeclared),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user