Files
EPEEAIKit/art-agent/backend/app/agent/loop.py

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Agent Loop 主循环LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
import asyncio
import json
import logging
import re
import os
from typing import AsyncGenerator, Optional
from openai import AsyncOpenAI
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
from app.config import (
get_llm_model,
get_llm_max_iterations,
get_image_model_config,
get_max_recent_turns,
)
from app.memory import get_memory
from app.services.image_gen import to_data_uri
logger = logging.getLogger(__name__)
def _get_client() -> AsyncOpenAI:
base_url = os.getenv("OPENAI_BASE_URL")
return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI()
SYSTEM_PROMPT = """\
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
## 你的能力
- 根据用户的文字描述生成图片UI图标、按钮、插画、立绘、概念图等
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
## 工作流程
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
2. 将需求转化为详细的英文 prompt调用 generate_image 工具生成图片
3. 向用户展示结果并询问反馈
4. 根据反馈调整 prompt 并重新生成
## 标注理解
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
- 区域坐标表示标注框在图片上的相对位置
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
## 生成 prompt 要求
- 必须使用英文
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button"
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你不要自行猜测画风/艺术风格关键词(如 pixel art、watercolor、oil painting 等),把风格交给参考图来决定。但如果用户在消息中明确指定了风格(如"赛博朋克风""水彩风"等),应保留并翻译到 prompt 中——尊重用户的主动意图
## 注意事项
- 用中文和用户交流
- 生成图片后简要说明你使用的 prompt 思路
- 主动建议迭代方向
- **禁止模拟工具调用**:生成图片时必须实际调用 generate_image 工具,绝不能用文字描述"已生成"或假装工具已执行。如果需要生成多张图片,每张都必须单独调用工具
"""
async def run_agent_loop(
messages: list[dict],
ref_image_url: Optional[str] = None,
image_model: Optional[str] = None,
session_id: Optional[str] = None,
) -> AsyncGenerator[dict, None]:
"""
运行 Agent Loop以 SSE 事件流形式 yield 结果。
事件类型:
- text_delta: LLM 文字增量
- tool_start: 开始执行工具
- image_result: 图片生成结果
- memory_warning: 记忆存储异常(不中断对话)
- done: 完成
- error: 错误
"""
# ── 滑动窗口:截断过早的消息 ──
max_turns = get_max_recent_turns()
if len(messages) > max_turns:
messages = messages[-max_turns:]
# ── 记忆检索:用最新 user 消息语义检索相关记忆 ──
memory_block = ""
try:
memory = get_memory()
last_user_content = ""
for m in reversed(messages):
if m["role"] == "user":
last_user_content = m["content"]
break
if last_user_content:
search_kwargs = {"query": last_user_content, "user_id": "default_user", "limit": 10}
if session_id:
search_kwargs["run_id"] = session_id
relevant = memory.search(**search_kwargs)
results = relevant.get("results", []) if isinstance(relevant, dict) else relevant
if results:
items = "\n".join(f"- {m['memory']}" for m in results if m.get("memory"))
if items:
memory_block = f"\n\n## 用户记忆(来自历史对话)\n{items}"
except Exception as e:
logger.error("Mem0 记忆检索失败: %s", e, exc_info=True)
yield {"type": "error", "data": {"message": f"记忆系统检索失败: {e}"}}
return
# ── 构建 system prompt ──
model_config = get_image_model_config(image_model)
current_model_name = model_config.get('name', '未知')
current_model_id = model_config.get('id', '未知')
model_hint = (
f"\n\n## 当前生图模型(重要)\n"
f"本次对话用户选择的生图模型是 **{current_model_name}**"
f"model_id: {current_model_id})。\n"
f"**注意**:对话历史中可能包含之前使用其他模型的记录,忽略那些旧模型名。"
f"本次生成使用的是 {current_model_name},在回复中只能使用这个名称。"
)
api_messages = [{"role": "system", "content": SYSTEM_PROMPT + model_hint + memory_block}]
# 检测当前 LLM 是否支持 vision多模态图片输入
llm_model = get_llm_model().lower()
vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude"))
for msg in messages:
if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
if vision_capable:
api_messages.append({
"role": "user",
"content": [
{"type": "text", "text": msg["content"]},
{
"type": "image_url",
"image_url": {"url": to_data_uri(ref_image_url)},
},
],
})
else:
hint = (
f"{msg['content']}\n\n"
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
"IP-Adapter 会从参考图中提取风格并融合到生成结果中。"
"你无法看到这张参考图,因此在生成 prompt 时:\n"
"1. 描述画面内容(主体、构图、光照、材质等)\n"
"2. 不要自行猜测画风/艺术风格——但如果用户明确指定了风格,保留到 prompt 中\n"
"3. 用户未指定风格时,风格完全由参考图通过 IP-Adapter 决定】"
)
api_messages.append({"role": "user", "content": hint})
continue
api_messages.append({"role": msg["role"], "content": msg["content"]})
client = _get_client()
for _ in range(get_llm_max_iterations()):
try:
response = await client.chat.completions.create(
model=get_llm_model(),
messages=api_messages,
tools=TOOL_DEFINITIONS,
stream=True,
)
except Exception as e:
yield {"type": "error", "data": {"message": str(e)}}
return
collected_text = ""
tool_calls_data: dict[int, dict] = {}
async for chunk in response:
delta = chunk.choices[0].delta if chunk.choices else None
if not delta:
continue
# 文字内容
if delta.content:
collected_text += delta.content
yield {"type": "text_delta", "data": {"text": delta.content}}
# 工具调用(流式累积)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
if idx not in tool_calls_data:
tool_calls_data[idx] = {
"id": "",
"name": "",
"arguments": "",
}
if tc.id:
tool_calls_data[idx]["id"] = tc.id
if tc.function and tc.function.name:
tool_calls_data[idx]["name"] = tc.function.name
if tc.function and tc.function.arguments:
tool_calls_data[idx]["arguments"] += tc.function.arguments
finish_reason = chunk.choices[0].finish_reason if chunk.choices else None
# 如果没有工具调用:检测 LLM 是否在用文字模拟生图
if not tool_calls_data:
if _looks_like_fake_generation(collected_text):
# LLM 用文字假装调用了工具,丢弃这段文字,注入纠正消息强制重试
yield {
"type": "text_delta",
"data": {"text": "\n\n[系统:检测到未调用生图工具,正在重试...]\n"},
}
api_messages.append({"role": "assistant", "content": collected_text})
api_messages.append({
"role": "user",
"content": (
"你刚才没有调用 generate_image 工具,只是用文字描述了生成过程。"
"请立即调用 generate_image 工具来实际生成图片。"
"不要解释,直接调用工具。"
f"当前使用的生图模型是 {model_config.get('name', '未知')}"
),
})
continue
# 对话正常结束,异步存储记忆
async for evt in _store_and_done(messages, session_id):
yield evt
return
# 将 assistant 消息(含 tool_calls加入历史
assistant_msg: dict = {"role": "assistant"}
if collected_text:
assistant_msg["content"] = collected_text
else:
assistant_msg["content"] = None
assistant_msg["tool_calls"] = []
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
assistant_msg["tool_calls"].append({
"id": tc_data["id"],
"type": "function",
"function": {
"name": tc_data["name"],
"arguments": tc_data["arguments"],
},
})
api_messages.append(assistant_msg)
# 依次执行每个工具调用
for idx in sorted(tool_calls_data.keys()):
tc_data = tool_calls_data[idx]
tool_name = tc_data["name"]
yield {
"type": "tool_start",
"data": {"tool": tool_name, "message": "正在生成图片..."},
}
try:
arguments = json.loads(tc_data["arguments"])
except json.JSONDecodeError:
arguments = {}
result = await execute_tool(tool_name, arguments, ref_image_url, image_model)
used_model = result.get("model_name", "")
if result.get("errors"):
yield {
"type": "tool_error",
"data": {
"tool": tool_name,
"errors": result["errors"],
"model_name": used_model,
},
}
if result.get("images"):
yield {
"type": "image_result",
"data": {
"images": result["images"],
"prompt_used": result.get("prompt_used", ""),
"model_name": used_model,
},
}
# 工具结果回传给 LLM
api_messages.append({
"role": "tool",
"tool_call_id": tc_data["id"],
"content": json.dumps(result, ensure_ascii=False),
})
# ref_image_url 不清除:消息构建只在循环外执行一次,
# 后续工具调用仍需参考图InstantStyle 等模型必须有 style_image
# 迭代次数用尽,存储记忆后结束
async for evt in _store_and_done(messages, session_id):
yield evt
# ─── 异步记忆存储 ─────────────────────────────────────────
async def _store_and_done(
messages: list[dict], session_id: Optional[str]
) -> AsyncGenerator[dict, None]:
"""触发 Mem0 异步存储后 yield done。存储失败时 yield warning 但不中断。"""
try:
memory = get_memory()
recent = messages[-4:] if len(messages) >= 4 else messages
add_kwargs: dict = {"user_id": "default_user"}
if session_id:
add_kwargs["run_id"] = session_id
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: memory.add(recent, **add_kwargs))
except Exception as e:
logger.error("Mem0 记忆存储失败: %s", e, exc_info=True)
yield {
"type": "memory_warning",
"data": {"message": f"记忆存储失败: {e}"},
}
yield {"type": "done", "data": {}}
# ─── 假生成检测 ──────────────────────────────────────────
_FAKE_GEN_PATTERNS = re.compile(
r"已生成|生成完成|开始生成|正在生成|图片已|prompt.*?设计思路",
re.IGNORECASE,
)
def _looks_like_fake_generation(text: str) -> bool:
"""判断 LLM 的纯文字回复是否在假装已经调用了生图工具。"""
if not text or len(text) < 50:
return False
matches = _FAKE_GEN_PATTERNS.findall(text)
return len(matches) >= 2