feat: 自进化经验记忆——任务成功后用模型把工具序列提炼成「操作配方」存入 agent_experience 表;下次相似任务(bigram 相似度检索 top2)自动注入 system prompt 参考,AI 越用越聪明(同类操作不再摸索)
This commit is contained in:
+123
-1
@@ -60,6 +60,101 @@ def _read_cfg():
|
||||
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"])
|
||||
@admin_required
|
||||
@@ -193,12 +288,21 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
def on_delta(text, kind):
|
||||
q.put(("delta", {"text": text, "kind": kind}))
|
||||
|
||||
tool_seq = [] # 本轮工具序列(经验提炼用)
|
||||
|
||||
def on_tool(step):
|
||||
rec = {"tool": step.get("tool"),
|
||||
"args": str(step.get("args"))[:200]}
|
||||
if step.get("image"):
|
||||
rec["image"] = _shrink_image(step["image"])
|
||||
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.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 ""
|
||||
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():
|
||||
await agent._load_tools()
|
||||
return await agent.run_stream(prompt, target,
|
||||
history=history,
|
||||
on_delta=on_delta, on_tool=on_tool,
|
||||
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))
|
||||
@@ -230,6 +343,15 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
|
||||
_run["history"] = hist[-24:]
|
||||
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:
|
||||
_log.warning(f"Agent 运行异常: {e}")
|
||||
with _lock:
|
||||
|
||||
Reference in New Issue
Block a user