feat: 自进化经验记忆——任务成功后用模型把工具序列提炼成「操作配方」存入 agent_experience 表;下次相似任务(bigram 相似度检索 top2)自动注入 system prompt 参考,AI 越用越聪明(同类操作不再摸索)
This commit is contained in:
+4
-1
@@ -135,7 +135,7 @@ class Agent:
|
|||||||
# ---------- 主循环(流式) ----------
|
# ---------- 主循环(流式) ----------
|
||||||
async def run_stream(self, prompt: str, serial: str = "",
|
async def run_stream(self, prompt: str, serial: str = "",
|
||||||
history=None, on_delta=None, on_tool=None,
|
history=None, on_delta=None, on_tool=None,
|
||||||
should_stop=None):
|
should_stop=None, extra_context=None):
|
||||||
"""流式执行一轮指令,返回最终完整文本。
|
"""流式执行一轮指令,返回最终完整文本。
|
||||||
|
|
||||||
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
||||||
@@ -143,6 +143,7 @@ class Agent:
|
|||||||
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
|
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
|
||||||
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
||||||
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
||||||
|
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
|
||||||
"""
|
"""
|
||||||
self.on_delta = on_delta
|
self.on_delta = on_delta
|
||||||
self.on_tool = on_tool
|
self.on_tool = on_tool
|
||||||
@@ -150,6 +151,8 @@ class Agent:
|
|||||||
sys_txt = SYSTEM_PROMPT
|
sys_txt = SYSTEM_PROMPT
|
||||||
if target:
|
if target:
|
||||||
sys_txt += f"\n\n本次默认目标设备 serial:{target}(未指定设备时用它)。"
|
sys_txt += f"\n\n本次默认目标设备 serial:{target}(未指定设备时用它)。"
|
||||||
|
if extra_context:
|
||||||
|
sys_txt += f"\n\n## 过往成功经验参考(同类任务,可参考其中的操作套路,但要根据当前界面灵活调整)\n{extra_context}"
|
||||||
self.messages = [{"role": "system", "content": sys_txt}]
|
self.messages = [{"role": "system", "content": sys_txt}]
|
||||||
for h in (history or []):
|
for h in (history or []):
|
||||||
if h.get("role") in ("user", "assistant") and h.get("content"):
|
if h.get("role") in ("user", "assistant") and h.get("content"):
|
||||||
|
|||||||
+123
-1
@@ -60,6 +60,101 @@ def _read_cfg():
|
|||||||
return {k: _meta_get(v) for k, v in _CFG_KEYS.items()}
|
return {k: _meta_get(v) for k, v in _CFG_KEYS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 经验记忆(自进化) ==================
|
||||||
|
# agent_experience:任务成功后的操作配方,下次相似任务检索注入 system prompt。
|
||||||
|
# 原始 SQLite(CREATE IF NOT EXISTS 幂等),不进模型层迁移。
|
||||||
|
_EXP_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_experience (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_prompt TEXT DEFAULT '',
|
||||||
|
recipe TEXT DEFAULT '',
|
||||||
|
tool_seq TEXT DEFAULT '',
|
||||||
|
hits INTEGER DEFAULT 0,
|
||||||
|
created_at VARCHAR(20) DEFAULT '')"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_exp_table():
|
||||||
|
try:
|
||||||
|
db.session.execute(db.text(_EXP_TABLE))
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _bigrams(text):
|
||||||
|
"""中文/英文文本 bigram 集合(无空格分词,粗粒度相似度)。"""
|
||||||
|
t = "".join(c for c in (text or "").lower() if c.isalnum() or "\u4e00" <= c <= "\u9fff")
|
||||||
|
return {t[i:i + 2] for i in range(len(t) - 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_experiences(prompt, limit=2, threshold=0.10):
|
||||||
|
"""按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。"""
|
||||||
|
try:
|
||||||
|
_ensure_exp_table()
|
||||||
|
rows = db.session.execute(db.text(
|
||||||
|
"SELECT task_prompt, recipe, hits FROM agent_experience "
|
||||||
|
"WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if not rows:
|
||||||
|
return ""
|
||||||
|
cur = _bigrams(prompt)
|
||||||
|
if not cur:
|
||||||
|
return ""
|
||||||
|
scored = []
|
||||||
|
for task_prompt, recipe, hits in rows:
|
||||||
|
sim = len(cur & _bigrams(task_prompt)) / len(cur)
|
||||||
|
if sim >= threshold:
|
||||||
|
scored.append((sim, hits or 0, recipe))
|
||||||
|
scored.sort(key=lambda x: (-x[0], -x[1]))
|
||||||
|
parts = []
|
||||||
|
for sim, _hits, recipe in scored[:limit]:
|
||||||
|
parts.append(f"- {recipe[:600]}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _distill_experience(cfg, prompt, tool_seq):
|
||||||
|
"""任务完成后用模型把操作序列提炼为可复用配方(失败静默,不阻塞)。"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
body = {
|
||||||
|
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
|
||||||
|
"messages": [{"role": "user",
|
||||||
|
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
|
||||||
|
"「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
|
||||||
|
"供下次同类任务参考。不要解释,直接输出配方。\n"
|
||||||
|
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
|
||||||
|
"max_tokens": 600,
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
|
||||||
|
json=body, headers=headers, timeout=60)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return ""
|
||||||
|
j = r.json()
|
||||||
|
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||||
|
return recipe.strip()[:1500]
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"经验提炼失败: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _save_experience(prompt, recipe, tool_seq):
|
||||||
|
try:
|
||||||
|
_ensure_exp_table()
|
||||||
|
from datetime import datetime
|
||||||
|
db.session.execute(db.text(
|
||||||
|
"INSERT INTO agent_experience(task_prompt, recipe, tool_seq, hits, created_at) "
|
||||||
|
"VALUES(:p, :r, :t, 0, :c)"),
|
||||||
|
{"p": prompt[:500], "r": recipe, "t": tool_seq[:1000],
|
||||||
|
"c": datetime.now().strftime("%Y-%m-%d %H:%M")})
|
||||||
|
db.session.commit()
|
||||||
|
_log.info("经验已保存(配方 %d 字符)", len(recipe))
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"经验保存失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ================== 配置 ==================
|
# ================== 配置 ==================
|
||||||
@bp.route("/api/agent/config", methods=["GET"])
|
@bp.route("/api/agent/config", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
@@ -193,12 +288,21 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
def on_delta(text, kind):
|
def on_delta(text, kind):
|
||||||
q.put(("delta", {"text": text, "kind": kind}))
|
q.put(("delta", {"text": text, "kind": kind}))
|
||||||
|
|
||||||
|
tool_seq = [] # 本轮工具序列(经验提炼用)
|
||||||
|
|
||||||
def on_tool(step):
|
def on_tool(step):
|
||||||
rec = {"tool": step.get("tool"),
|
rec = {"tool": step.get("tool"),
|
||||||
"args": str(step.get("args"))[:200]}
|
"args": str(step.get("args"))[:200]}
|
||||||
if step.get("image"):
|
if step.get("image"):
|
||||||
rec["image"] = _shrink_image(step["image"])
|
rec["image"] = _shrink_image(step["image"])
|
||||||
q.put(("step", rec))
|
q.put(("step", rec))
|
||||||
|
# 记录精简工具序列
|
||||||
|
try:
|
||||||
|
args = step.get("args") or {}
|
||||||
|
brief = {k: v for k, v in args.items() if k != "serial"}
|
||||||
|
tool_seq.append(f"{step.get('tool')}({str(brief)[:60]})")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
agent = Agent()
|
agent = Agent()
|
||||||
agent.s.api_base = cfg.get("api_base") or agent.s.api_base
|
agent.s.api_base = cfg.get("api_base") or agent.s.api_base
|
||||||
@@ -211,13 +315,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
target = serial or cfg.get("default_serial") or ""
|
target = serial or cfg.get("default_serial") or ""
|
||||||
stop_evt = _stop_events.get(run_id)
|
stop_evt = _stop_events.get(run_id)
|
||||||
|
|
||||||
|
# 经验检索:相似历史任务的操作配方注入 system(自进化记忆)
|
||||||
|
exp_ctx = _find_experiences(prompt)
|
||||||
|
if exp_ctx:
|
||||||
|
_log.info("命中历史经验,注入参考配方")
|
||||||
|
q.put(("step", {"tool": "🧠 经验记忆",
|
||||||
|
"args": f"命中 {exp_ctx.count(chr(10) + '- ')} 条同类历史经验,已注入参考",
|
||||||
|
"image": None}))
|
||||||
|
|
||||||
async def _execute():
|
async def _execute():
|
||||||
await agent._load_tools()
|
await agent._load_tools()
|
||||||
return await agent.run_stream(prompt, target,
|
return await agent.run_stream(prompt, target,
|
||||||
history=history,
|
history=history,
|
||||||
on_delta=on_delta, on_tool=on_tool,
|
on_delta=on_delta, on_tool=on_tool,
|
||||||
should_stop=lambda: bool(
|
should_stop=lambda: bool(
|
||||||
stop_evt and stop_evt.is_set()))
|
stop_evt and stop_evt.is_set()),
|
||||||
|
extra_context=exp_ctx)
|
||||||
|
|
||||||
# 整体超时保护:卡死时结束,释放单实例
|
# 整体超时保护:卡死时结束,释放单实例
|
||||||
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
||||||
@@ -230,6 +343,15 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
|||||||
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
|
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
|
||||||
_run["history"] = hist[-24:]
|
_run["history"] = hist[-24:]
|
||||||
q.put(("done", {"answer": answer}))
|
q.put(("done", {"answer": answer}))
|
||||||
|
|
||||||
|
# 自进化:成功后提炼操作配方存为经验(尽力而为,不阻塞/不影响结果)
|
||||||
|
if tool_seq:
|
||||||
|
try:
|
||||||
|
recipe = _distill_experience(cfg, prompt, " -> ".join(tool_seq))
|
||||||
|
if recipe and "配方" not in recipe[:50]:
|
||||||
|
_save_experience(prompt, recipe, " -> ".join(tool_seq))
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"经验保存异常: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"Agent 运行异常: {e}")
|
_log.warning(f"Agent 运行异常: {e}")
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
Reference in New Issue
Block a user