diff --git a/doc/API.md b/doc/API.md index 3a6232c..48c4090 100644 --- a/doc/API.md +++ b/doc/API.md @@ -1186,15 +1186,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b ```json {"ok": true, "state": "running"|"idle"|"done", "run_id": "...", "prompt": "...", "serial": "...", "started": "10:00:01", "answer": "...", "error": "", + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "calls": 0}, "history": [{"role": "user", "content": "..."}]} ``` +`usage` 为本轮累计 token 用量(`calls` = 模型调用次数;运行中实时增长,失败也保留已花费的)。 + ### GET /api/agent/stream?run_id= 订阅事件流(SSE,EventSource)。事件: - `event: delta` `{text, kind: content|reasoning}` — 流式文本增量 - `event: step` `{tool, args, image?}` — 工具调用完成(MCP 步骤,image 为缩略截图)。另有三类伪卡片:`tool="🧠 经验记忆"` 表示命中任务级经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」)或本轮已写入经验库;`tool="🧠 动作经验"` 表示命中**可复用动作**(「命中 N 个可复用动作,已注入参考:<动作名>」,执行前注入)或本轮已沉淀动作(「已沉淀 N 个可复用动作」,含元素定位、禁坐标) -- `event: done` `{answer}` — 完成 +- `event: usage` `{prompt_tokens, completion_tokens, total_tokens, calls}` — **本轮累计** token 用量,每完成一次模型调用推一次(前端实时刷新计数与费用感) +- `event: done` `{answer, usage}` — 完成(`usage` 同上一节,最终累计) - `event: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`) - 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流 @@ -1234,10 +1238,18 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b **响应**: ```json -{"ok": true, "id": "...", "title": "...", "messages": [{"role": "user", "content": "..."}], +{"ok": true, "id": "...", "title": "...", + "messages": [{"role": "user", "content": "..."}, + {"role": "assistant", "content": "...", + "usage": {"prompt_tokens": 0, "completion_tokens": 0, + "total_tokens": 0, "calls": 0}, + "reasoning": "模型推理链(≤6000 字符,可空)"}], "created_at": "...", "updated_at": "..."} ``` +> assistant 消息可带 `usage`(本轮 token 用量)与 `reasoning`(推理链,上限 `_REASONING_KEEP`=6000 字符)。 +> 两者**仅供前端展示/回看**,回灌模型上下文时只取 `role`/`content`(见 `_agent_thread`)。 + ### DELETE /api/agent/conversations/ 删除会话(消息一并删除,不可恢复)。 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 68798fd..c89d94c 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -346,6 +346,19 @@ AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆: - **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。 - **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=`。 +#### 5.4.1 回答渲染与 token(2026-09-10) + +| 能力 | 落点 | 说明 | +|------|------|------| +| **Markdown 渲染** | `static/admin/markdown.js`(`renderMarkdown()`) | 自研轻量渲染器,**不引 CDN**(生产 220 在内网):标题/段落/软换行/粗斜体/删除线/行内代码/围栏代码块/有序无序列表(含嵌套)/引用/表格/分隔线/链接。**先 `esc()` 转义再套标记**,模型输出里的 HTML 只显示为文本(防注入) | +| **推理链可折叠** | `agent.js` `_appendReasoning()` + `
` | 流式思考时自动展开、正文开始时自动收起;用户手动点过 `summary` 后不再自动改(`dataset.touched`);摘要显示「思考过程(N 字)」 | +| **token 显示** | `mcp_agent/agent.py` `_accumulate_usage()` + `agent_api` SSE `usage` 事件 | 每次模型调用完成后推**本轮累计**(`prompt/completion/total/calls`);单条消息脚注 + 顶栏「本会话累计」(历史 + 运行中) | +| **推理链/用量落库** | `_agent_thread` 把 `usage`、`reasoning` 写进会话 assistant 消息 | 刷新页面后仍可回看;**回灌模型上下文时只取 `role`/`content`**(不污染 token) | + +> **token 采集的兼容性**:请求带 `stream_options: {"include_usage": true}`,按「每次模型调用」取末尾 chunk 的 `usage` 累加(多轮工具调用会多次累加)。个别网关不认该参数会直接 **HTTP 400** → `_UsageUnsupported` 捕获后**自动关掉并重试一次**(`self._include_usage=False`),不影响主流程。 +> +> **推理链体积**:只保留前 `_REASONING_KEEP`=6000 字符落库(会话消息上限 60 条),避免历史无限膨胀。 + > **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略: > 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复; > 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试); diff --git a/doc/DEVELOPMENT.md b/doc/DEVELOPMENT.md index 18b981f..5ba534b 100644 --- a/doc/DEVELOPMENT.md +++ b/doc/DEVELOPMENT.md @@ -108,7 +108,7 @@ - **任务参数放各自 `tasks//task.py` 顶部,不放 `config.py`** - **生产环境(220)默认只读**:任何写操作(改文件/重启容器/部署)都必须先经负责人确认 - **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git -- **前端 JS 已拆分多文件**(均位于 `static/admin/`):`monitor.html` 按 `base.js → list.js → monitor.js → editor.js → tasks.js → tools.js → apps.js → admin.js → agent.js → system.js` 的顺序用 ` + diff --git a/web/agent_api.py b/web/agent_api.py index 5dbeecd..3f1edc0 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -5,7 +5,9 @@ GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅): event: delta {text, kind: content|reasoning} 流式文本增量 event: step {tool, args, image?} 工具调用完成(MCP 步骤) - event: done {answer} 完成 + event: usage {prompt_tokens, completion_tokens, + total_tokens, calls} 本轮累计 token 用量 + event: done {answer, usage} 完成 event: error {message} 失败 GET/POST /api/agent/config → 配置读写(key 打码回显) @@ -38,9 +40,13 @@ _CFG_KEYS = {"api_base": "agent_api_base", "default_serial": "agent_default_serial", "max_steps": "agent_max_steps"} +# 推理链落库上限(字符):只留够回看的量,避免会话消息无限膨胀 +_REASONING_KEEP = 6000 + + # ---------- 运行状态(单实例 + 事件队列) ---------- _run = {"id": None, "state": "idle", "prompt": "", "serial": "", - "answer": "", "error": "", + "answer": "", "error": "", "usage": {}, "history": []} # 多轮对话历史 [{role: user|assistant, content}] _queues = {} # run_id -> queue.Queue(SSE 消费者读取) _stop_events = {} # run_id -> threading.Event(用户中断) @@ -1192,7 +1198,7 @@ def agent_run(): _run.update(id=run_id, state="running", prompt=prompt, serial=serial, conv_id=conv_id, started=_dt.now().strftime("%H:%M:%S"), - answer="", error="") + answer="", error="", usage={}) # history 保留(多轮上下文),由会话/「新建会话」管理 _queues[run_id] = queue.Queue() _stop_events[run_id] = threading.Event() @@ -1244,6 +1250,7 @@ def agent_run_status(): "started": _run.get("started", ""), "answer": (_run.get("answer") or "")[:4000], "error": (_run.get("error") or "")[:400], + "usage": _run.get("usage") or {}, "history": hist[-16:]}) @@ -1345,9 +1352,22 @@ def _agent_thread(run_id, prompt, serial, cfg): sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from mcp_agent.agent import Agent + # 推理链(reasoning_content)累计——单纯流式展示会随页面刷新丢失, + # 落库后可在会话里折叠回看(只存文本,不回灌给模型) + reason_parts = [] + reason_len = 0 + def on_delta(text, kind): + nonlocal reason_len + if kind == "reasoning" and reason_len < _REASONING_KEEP: + reason_parts.append(text) + reason_len += len(text) q.put(("delta", {"text": text, "kind": kind})) + def on_usage(usage): + """每次模型调用完成 → 推累计用量(前端实时刷新 token 计数)。""" + q.put(("usage", dict(usage))) + tool_seq = [] # 本轮工具序列(任务级配方提炼用) tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败) @@ -1451,7 +1471,8 @@ def _agent_thread(run_id, prompt, serial, cfg): on_delta=on_delta, on_tool=on_tool, should_stop=lambda: bool( stop_evt and stop_evt.is_set()), - extra_context=recall_ctx) + extra_context=recall_ctx, + on_usage=on_usage) except Exception as e: if _mcp_unreachable(e): raise RuntimeError( @@ -1460,13 +1481,22 @@ def _agent_thread(run_id, prompt, serial, cfg): # 整体超时保护:卡死时结束,释放单实例 answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900)) + usage = dict(getattr(agent, "usage", None) or {}) with _lock: _run["state"] = "done" _run["answer"] = answer - # 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀) + _run["usage"] = usage + # 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)。 + # usage/reasoning 仅用于前端展示与落库,不进模型上下文(读回时只取 role/content) hist = _run.setdefault("history", []) hist.append({"role": "user", "content": prompt[:2000]}) - hist.append({"role": "assistant", "content": (answer or "")[:4000]}) + turn = {"role": "assistant", "content": (answer or "")[:4000]} + if usage: + turn["usage"] = usage + reason = "".join(reason_parts).strip() + if reason: + turn["reasoning"] = reason[:_REASONING_KEEP] + hist.append(turn) _run["history"] = hist[-24:] # 会话落库:本轮追加写回(新会话自动用首条消息作标题)。 # 后台线程 db 访问需 app context。 @@ -1513,7 +1543,7 @@ def _agent_thread(run_id, prompt, serial, cfg): "image": None})) except Exception as e: _log.warning(f"经验保存异常: {e}") - q.put(("done", {"answer": answer})) + q.put(("done", {"answer": answer, "usage": usage})) except Exception as e: _log.warning(f"Agent 运行异常: {e}") # 诊断:打印消息结构(tool_calls 与 tool 消息配对检查) @@ -1529,6 +1559,11 @@ def _agent_thread(run_id, prompt, serial, cfg): with _lock: _run["state"] = "error" _run["error"] = f"{type(e).__name__}: {str(e)[:200]}" + # 失败也保留已花费的 token(前端仍能展示本轮用量;agent 可能未建出来) + try: + _run["usage"] = dict(agent.usage or {}) + except Exception: + pass q.put(("error", {"message": str(e)[:200]})) finally: q.put(None) # 关闭 SSE