diff --git a/mcp_agent/agent.py b/mcp_agent/agent.py index ec21534..ca8f9d3 100644 --- a/mcp_agent/agent.py +++ b/mcp_agent/agent.py @@ -135,7 +135,7 @@ class Agent: # ---------- 主循环(流式) ---------- async def run_stream(self, prompt: str, serial: str = "", history=None, on_delta=None, on_tool=None, - should_stop=None): + should_stop=None, extra_context=None): """流式执行一轮指令,返回最终完整文本。 history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表, @@ -143,6 +143,7 @@ class Agent: on_delta(text, kind):content/reasoning 文本增量(实时推给前端) on_tool(step):工具调用完成(实时显示 MCP 步骤) should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用) + extra_context:附加文本(经验记忆注入,放在 system prompt 末尾) """ self.on_delta = on_delta self.on_tool = on_tool @@ -150,6 +151,8 @@ class Agent: sys_txt = SYSTEM_PROMPT if 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}] for h in (history or []): if h.get("role") in ("user", "assistant") and h.get("content"): diff --git a/web/agent_api.py b/web/agent_api.py index 5a7cf8e..ede6239 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -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: