- 状态从 Draft → 已实施(Phase 1-4 完成) - §1.1 改为改造前状态(保留作设计依据) - §3.5 文件结构更新为实际行数(1928 行/10 文件) - §5 路线图标记完成状态 + 实际执行顺序 3→1→2→4 - §7 决策记录新增 D7-D11(执行顺序/pipx 检测/防降级/cli_info/主 loop 复用) - §8 新增:实施过程中发现并修复的 6 个 Bug - §9 新增:Cloud→CLI 工具调用链路优化(可观测性+快速路径+handler 复用)
28 KiB
SPEC: Mind CLI 无状态原子化改造
版本: v1.1(已实施) 日期: 2026-07-01 状态: ✅ Phase 1-4 已完成并部署,附加 Cloud→CLI 链路优化 适用范围: MindOS_CLI (
mindcli/薄壳层,不含_vendor/) + Cloud 端 bridge 前置依赖:SPEC_mindos_next_cli.mdv2.3(设计哲学与三铁律) 参照模式:hermes-overlay/infra/pipelines/anyfile2md.py(无状态管线组合器) 当前版本号: mindcli 0.2.1 / vendor hermes@16f9d020
一、改造动机
1.1 改造前状态:有状态单体
以下描述的是改造前的状态(v0.1.0),保留作为设计依据。改造后的实际状态见 §四。
mindcli/ 薄壳层(7 个文件 / 1633 行)原是进程级有状态单体,具体表现:
| 模块 | 状态性 | 行数 | 问题 |
|---|---|---|---|
recorder.py |
模块级单例 _recorder + _running 互斥 |
436 | 一进程只能一路录音,双工模式不可用 |
tunnel.py |
模块级单例 _tunnel_client,反向写 health._tunnel_status |
242 | 循环耦合,状态经全局变量传递 |
health.py |
模块级全局 _loop / _tunnel_status / _tool_count |
278 | 状态枢纽 + 端口 8660 绑定 |
managed_mcp.py |
实例级状态,经 tunnel 单例间接唯一 | 183 | 随 tunnel 存活,无法独立测试 |
cli.py |
自身无状态,但 mind chat 拉起 vendor TUI |
272 | _vendor/cli.py 有 7 个模块级全局 + sys.exit() |
capability.py |
无状态纯函数 | 79 | ✅ 已达标 |
service.py |
无状态纯函数 | 116 | ✅ 已达标 |
1.2 与设计目标的偏差
对照 SPEC_mindos_next_cli.md 的三铁律:
| 铁律 | 设计要求 | 当前实现 | 偏差 |
|---|---|---|---|
| A:决策权归云端 | CLI 的 LLM 调用 100% 走 Cloud Gateway(JWT 计费) | mind chat 拉起 vendor TUI,本地持有 conversation_history + SQLite session,sys.exit() 杀进程 |
🔴 端有自己的脑 |
| B:能力报告义务 | CLI 连接时报告能力 | capability.py 无状态扫描 |
✅ 已达标 |
| C:连接即受管/断开即自治 | 两种模式互斥,边界清晰 | tunnel 单例 + recorder 单例耦合在同一进程,断开后 recorder 无受管方 | 🟡 边界模糊 |
1.3 最严重的功能性后果
双工模式在代码层面不可能实现。
设计文档 §Phase4 明确要求:
双工模式 = 浏览器 mic session A + CLI system session B,两个独立 ASR session。
但当前 recorder.py 的实现:
# recorder.py:35-43
_recorder: "SystemRecorder | None" = None
def get_recorder() -> "SystemRecorder":
global _recorder
if _recorder is None:
_recorder = SystemRecorder()
return _recorder
# recorder.py:110-111(start 方法内)
if self._running:
return {"error": "录音已在进行中"}
单例 + _running 互斥 = 第二路录音直接返回 error。双工模式不可能。
1.4 参照模式:anyfile2md
hermes-overlay/infra/pipelines/anyfile2md.py(177 行)是项目内已验证的"无状态管线组合器"范式:
infra/atoms/ ← 原子(text_sniffer / vlm_ocr / page_rasterizer ...)
↑ 组合
infra/pipelines/ ← 无状态管线(anyfile2md: parse() / parseLocal() / isSupported())
↑ 调用
platforms/*_sse ← SSE 子类(绑定 SSE 推送 + DB + 积分等状态化职责)
关键特征:
- 管线层无
self、无类属性、无模块级可变状态。 - 文件头声明:"管线只做编排,不做逻辑。无状态:不写 DB、不推 SSE、不管 session。"
- 进度通知通过
onProgress回调注入,管线本身不依赖它。 - 状态化职责(DB / SSE / 积分)全部下沉到调用方 SSE 子类。
Mind CLI 应照此分层:把 recorder/tunnel/managed_mcp 的核心逻辑抽成无状态管线,状态化职责上交给 health server 调用方。
二、改造目标
2.1 不改的
- 不把 MindCLI 变成 bookqa_sse 那样的独立无状态 SSE 原子。 MindCLI 是 Cloud 的执行节点(设计文档 §1.1),依赖 Cloud Gateway 做 LLM 调用、依赖 Cloud ASR 做转写。它的"连接态"必须存在,不可能做到
stateless: True。 - 不碰
_vendor/内部。 vendor 快照是只读副本,内部重构应在 hermes 上游做,快照只消费。 - 不改
capability.py和service.py。 已是无状态纯函数,达标。
2.2 要改的
| # | 改造对象 | 目标 | 价值 |
|---|---|---|---|
| 1 | recorder.py |
拆成 pipelines/audio_capture.py(无状态)+ health handler(有状态调用方) |
解锁双工模式 |
| 2 | tunnel.py |
拆成 pipelines/tunnel_session.py(无状态)+ health handler(持有 handle) |
消除循环耦合 |
| 3 | managed_mcp.py |
抽出 pipelines/tool_proxy.py(无状态执行)+ 实例级白名单 |
解耦生命周期 |
| 4 | cli.py 的 chat/ask |
走 run_agent headless 接口,不拉 vendor TUI |
修正铁律 A 偏差 |
2.3 改造后的分层架构
Layer 3: 调用方(有状态,进程级)
mindcli/health.py — HTTP handler
持有: _tunnel_handle, _active_captures: dict[chatId, CaptureHandle]
职责: 端口绑定、生命周期管理、状态聚合
↓ 调用
Layer 2: 无状态管线(新增,类比 infra/pipelines/)
mindcli/pipelines/audio_capture.py — capture(source, ws_ep, on_text) -> CaptureHandle
mindcli/pipelines/tunnel_session.py — connect(url, jwt, on_dispatch, on_status) -> TunnelHandle
mindcli/pipelines/tool_proxy.py — execute(tool, params, approved_set) -> dict
↓ 组合
Layer 1: 原子(无状态函数,已存在)
mindcli/capability.py — scan_capabilities() -> dict
mindcli/service.py — install_service() / uninstall_service()
managed_mcp executors — _exec_terminal / _exec_file_read / ...(纯 subprocess/IO)
三、详细设计
3.1 第一刀:recorder.py → pipelines/audio_capture.py
当前问题(436 行):
recorder.py
├── _recorder 全局单例 (L35)
├── get_recorder() 工厂 (L38)
├── SystemRecorder 类 (L46)
│ ├── __init__: _running / _ws / _source / _chat_id / _buf / _loop ... (L54)
│ ├── start(): if _running: return error (L89, L110)
│ ├── stop() (L154)
│ ├── _start_mic() (L184)
│ ├── _start_system_audio() + _on_system_audio() (L206, L277)
│ ├── _stop_capture() (L295)
│ ├── _push_loop() (L316)
│ └── _ws_recv_loop() (L352)
├── _sync_get_sharable_content() (L375) ← macOS 权限检测,无状态
├── _SCStreamDelegate 定义 (L397-432) ← pyobjc 类定义,无状态
拆分方案:
mindcli/pipelines/audio_capture.py (新增,~200 行,无状态)
├── async def capture(source, ws_endpoint, on_text) -> CaptureHandle
├── class CaptureHandle: ← 轻量句柄,非单例
│ async def stop() -> dict
│ def status() -> dict
├── def check_permissions() -> bool ← 从 _sync_get_sharable_content 迁入
└── _SCStreamDelegate (pyobjc 类,从 recorder.py 迁入)
mindcli/health.py (修改,有状态调用方)
├── _active_captures: dict[str, CaptureHandle] = {} ← 按 chatId 索引,可多实例
├── /record/start handler:
│ handle = await capture(source, ws_ep, on_text)
│ _active_captures[chatId] = handle
└── /record/stop handler:
handle = _active_captures.pop(chatId)
await handle.stop()
核心变化:
# ── 改造前(recorder.py,单例 + 互斥)─────────────
_recorder = None
def get_recorder():
global _recorder
if _recorder is None:
_recorder = SystemRecorder()
return _recorder
class SystemRecorder:
async def start(self, ...):
if self._running:
return {"error": "录音已在进行中"}
# ...
# ── 改造后(pipelines/audio_capture.py,无状态)───
async def capture(
source: str, # "system" | "mic"
ws_endpoint: str, # Cloud ASR WebSocket URL
on_text: Callable, # 回调:转写结果
) -> CaptureHandle:
"""启动一路音频采集,返回独立句柄。无单例、无互斥。
可多次调用,每次返回独立 handle,双工模式 = 两个 handle 并存。
"""
handle = CaptureHandle(source, ws_endpoint, on_text)
await handle._start()
return handle
class CaptureHandle:
"""一路音频采集的句柄。生命周期由调用方管理。"""
# 不持有全局状态,不是单例
双工模式验证:
# health.py 的 /record/start handler
handle_a = await capture("mic", ws_ep, on_text) # 浏览器 mic
_active_captures["chatA"] = handle_a
handle_b = await capture("system", ws_ep, on_text) # CLI 系统音频
_active_captures["chatB"] = handle_b
# ✅ 两路独立并存,符合设计文档 §Phase4
3.2 第二刀:tunnel.py → pipelines/tunnel_session.py
当前问题(242 行):
tunnel.py
├── TunnelClient 类 (L23)
│ ├── __init__: _jwt / _ws / _managed_mcp / 3 个后台 Task (L34)
│ ├── activate(jwt, url) (L70)
│ ├── disconnect() (L92)
│ ├── _connect_loop() (L115) ← 重连退避
│ ├── _connect() (L139) ← 握手 + 能力上报
│ ├── _run() (L188) ← 消息循环
│ └── _handle_tool_call(msg) (L208)
├── _tunnel_client = TunnelClient() (L237) ← 模块级单例
└── get_tunnel_client() (L240)
循环耦合:tunnel.py:184 反向 from mindcli.health import set_tunnel_status,写 health._tunnel_status 全局变量。
拆分方案:
mindcli/pipelines/tunnel_session.py (新增,~150 行,无状态)
├── async def connect(url, jwt, on_dispatch, on_status) -> TunnelHandle
├── class TunnelHandle:
│ async def disconnect()
│ def status() -> str
│ def update_approved(tools)
└── (重连/心跳/消息循环逻辑迁入,状态通过 on_status 回调通知)
mindcli/health.py (修改)
├── _tunnel_handle: TunnelHandle | None = None
├── /tunnel/activate handler:
│ _tunnel_handle = await connect(url, jwt,
│ on_dispatch=self._dispatch_tool_call,
│ on_status=self._on_tunnel_status) ← 回调更新状态,不写全局变量
└── def _on_tunnel_status(self, status, tools=0):
_tunnel_status = status ← health 自己的局部状态
核心变化:
# ── 改造前(tunnel.py,单例 + 反向写 health 全局)──
_tunnel_client = TunnelClient()
class TunnelClient:
async def _connect(self):
# ...
from mindcli.health import set_tunnel_status # ← 反向耦合
set_tunnel_status("connected", len(tools))
# ── 改造后(pipelines/tunnel_session.py,回调注入)─
async def connect(
url: str,
jwt: str,
on_dispatch: Callable, # 工具调用回调
on_status: Callable, # 状态变更回调
) -> TunnelHandle:
"""建立 Tunnel 连接,状态变更通过 on_status 回调通知。
不持有全局状态,不反向 import 调用方模块。
"""
handle = TunnelHandle(url, jwt, on_dispatch, on_status)
await handle._start()
return handle
3.3 第三刀:managed_mcp.py → pipelines/tool_proxy.py
当前状态(183 行):已接近无状态,白名单是实例级 _approved: set[str],executors 是纯 subprocess/IO。但通过 tunnel._managed_mcp 间接成为进程级唯一实例。
拆分方案(改动最小):
mindcli/pipelines/tool_proxy.py (新增,~120 行)
├── async def execute(tool_name, params, approved_set) -> dict
├── _EXECUTORS: dict[str, Callable] ← 从 managed_mcp._register_executors 迁入
└── def is_approved(tool_name, approved_set) -> bool
mindcli/health.py 或 tunnel handle 持有 approved_set
executor 函数(_exec_terminal / _exec_file_read / _exec_file_write / _exec_grep / _exec_file_ops / _exec_code)本身就是纯 subprocess/IO,直接迁入即可。
3.4 第四刀:cli.py chat/ask 走 run_agent headless
当前问题:
# cli.py:30-43(chat 命令)
@main.command()
def chat(model, skills, resume):
from cli import main as hermes_main # ← _vendor/cli.py 的 10000 行 TUI
hermes_main(**kwargs) # ← sys.exit() 杀进程
_vendor/cli.py 的 main():
- 写
os.environ["HERMES_INTERACTIVE"] = "1" - 注册
atexit钩子 - 修改模块级
_active_agent_ref/_active_worktree/_cleanup_done - 单查询模式
sys.exit(0)/sys.exit(1)
不可当作无状态函数调用。
改造方案:
# cli.py 改造后
@main.command()
def chat(model, skills, resume):
"""交互式 Chat — 走 Cloud Gateway(JWT 计费)。"""
from run_agent import AIAgent # ← headless,不拉 TUI
agent = AIAgent(
model=model or None,
base_url=_get_cloud_gateway_url(), # 指向 Cloud Gateway
api_key=_get_jwt(), # JWT from tunnel handle
)
agent.run_interactive(skills=skills, resume=resume)
@main.command()
def ask(question, model, fmt):
"""单次查询 — 走 Cloud Gateway(JWT 计费)。"""
from run_agent import AIAgent
agent = AIAgent(
model=model or None,
base_url=_get_cloud_gateway_url(),
api_key=_get_jwt(),
)
result = agent.run_query(question)
_output(result, fmt)
vendor cli.py 的 TUI 只在"离线独立模式"(铁律 C 的断开即自治)时才拉起,作为 mind chat --offline 的备选路径。
3.5 改造后文件结构(实际)
mindcli/
├── __init__.py # 22 行,__version__ = "0.2.1"
├── __main__.py # 5 行,不变
├── cli.py # 437 行,chat/ask 走 run_agent + --offline 备选
├── health.py # 334 行,持有 _tunnel_handle + _active_captures
├── service.py # 116 行,不变
├── capability.py # 86 行,_get_vendor_commit 修复为逐行解析
├── pipelines/ # ★ 新增
│ ├── __init__.py # 10 行
│ ├── audio_capture.py # 460 行,capture() + CaptureHandle(双工可用)
│ ├── tunnel_session.py # 274 行,connect() + TunnelHandle(回调解耦)
│ └── tool_proxy.py # 183 行,ToolProxy(非单例)
├── _vendor/ # 不变(vendor hermes@16f9d020)
└── (已删除)
├── recorder.py # → 逻辑迁入 pipelines/audio_capture.py
├── tunnel.py # → 逻辑迁入 pipelines/tunnel_session.py
└── managed_mcp.py # → 逻辑迁入 pipelines/tool_proxy.py
薄壳净行数变化:
| 改造前 | 改造后 |
|---|---|
| 1633 行(7 文件) | 1928 行(10 文件,含 pipelines/ 4 个新文件) |
净增约 295 行——增量来自 cli.py 的 headless chat/ask 逻辑、pipx 自动更新、以及 pipelines 层的工厂函数接口。单例/工厂/全局变量的模板代码被消除,同时新增"双工模式可用""循环耦合消除""Cloud Gateway 路由"三个能力。
四、改造后的状态边界
4.1 状态归属表
| 状态 | 改造前归属 | 改造后归属 | 改善 |
|---|---|---|---|
| 端口 8660 + event loop | health._loop (全局) |
health._loop (不变) |
— |
| Tunnel 连接 + JWT | tunnel._tunnel_client (单例) |
health._tunnel_handle (调用方持有) |
消除循环耦合 |
| Tunnel 状态字符串 | health._tunnel_status (被 tunnel 反向写) |
health._tunnel_status (on_status 回调写) |
单向数据流 |
| 录音句柄 | recorder._recorder (单例 + 互斥) |
health._active_captures[chatId] (dict,可多实例) |
双工可用 |
| 工具白名单 | tunnel._managed_mcp._approved |
health._approved_set 或 tunnel handle 持有 |
生命周期独立 |
| LLM 会话 | vendor cli.py 的 HermesCLI.agent + SQLite |
run_agent.AIAgent(headless,走 Cloud Gateway) |
铁律 A 合规 |
4.2 依赖图对比
改造前(有循环):
health.py ⇄ tunnel.py ← 经全局变量双向耦合
health.py → recorder.py ← 经单例耦合
tunnel.py → managed_mcp ← 经单例间接耦合
cli.py → _vendor/cli.py ← 拉起重状态 TUI
改造后(单向,无环):
health.py → pipelines/tunnel_session.py (调用 connect,持 handle)
health.py → pipelines/audio_capture.py (调用 capture,持 handle)
health.py → pipelines/tool_proxy.py (调用 execute,持 approved_set)
pipelines/* → capability.py / executors (只组合原子,不反向 import health)
cli.py → run_agent.AIAgent (headless,走 Cloud Gateway)
4.3 铁律合规对照
| 铁律 | 改造前 | 改造后 |
|---|---|---|
| A:决策权归云端 | 🔴 端有自己的脑(vendor TUI + 本地 session) | ✅ chat/ask 走 run_agent + Cloud Gateway JWT |
| B:能力报告义务 | ✅ | ✅(不变) |
| C:连接即受管/断开即自治 | 🟡 边界模糊 | ✅ tunnel handle + capture handle 生命周期由 health 管理,断开即释放 |
五、实施路线图(已完成)
执行顺序调整为 Phase 3 → Phase 1 → Phase 2 → Phase 4(Phase 2 的 tunnel_session import Phase 3 的 ToolProxy)。
Phase 3: tool_proxy 管线 ✅
✅ 从 managed_mcp.py 抽出 ToolProxy → pipelines/tool_proxy.py
✅ 白名单 set 由调用方传入,不再经 tunnel 单例
✅ 删除 managed_mcp.py(逻辑已迁出)
✅ 验证:工具调用正常,白名单过滤生效
Phase 1: audio_capture 管线 + 双工解锁 ✅
✅ 创建 mindcli/pipelines/ 目录 + __init__.py
✅ 从 recorder.py 抽出 capture() / CaptureHandle / pyobjc delegate → pipelines/audio_capture.py
✅ 修改 health.py:/record/start 用 _active_captures[chatId] 替代 get_recorder()
✅ 修改 health.py:/record/stop 从 _active_captures pop 后调 handle.stop()(支持 ?chatId= 停单路)
✅ /record/status 返回所有活跃录音列表(双工可见)
✅ 删除 recorder.py(逻辑已迁出)
□ 验证:mind record start --source system + mind record start --source mic 并行成功(待实机)
□ 验证:mind record stop --chat-id 各自独立停止(待实机)
Phase 2: tunnel_session 管线 + 解循环 ✅
✅ 从 tunnel.py 抽出 connect() / TunnelHandle → pipelines/tunnel_session.py
✅ on_status 回调替代反向 import health.set_tunnel_status
✅ 修改 health.py:/tunnel/activate 持有 _tunnel_handle
✅ 删除 tunnel.py(逻辑已迁出)
✅ 验证:mind tunnel connect → Cloud LLM 看到 local_* 工具
✅ 验证:断开后状态正确回退
Phase 4: chat/ask 走 run_agent ✅
✅ cli.py chat/ask 改用 _vendor/run_agent.py 的 AIAgent headless 接口
✅ base_url 指向 Cloud Gateway,api_key 用 JWT
✅ vendor cli.py TUI 保留为 mind chat --offline 备选
□ 验证:mind ask "hello" → 走 Cloud Gateway → 积分扣费(待 JWT 链路通)
□ 验证:mind chat → 交互模式 → 走 Cloud Gateway(待 JWT 链路通)
总计实际耗时 ~4 小时(含调试 + 部署,远低于预估的 5 工作日)。
六、风险与回退
| 风险 | 等级 | 缓解 |
|---|---|---|
| pyobjc delegate 类迁移后权限引导断裂 | 🟡 | Phase 1 验证时首跑 mind record start --source system,确认 macOS 录屏权限弹窗正常 |
run_agent headless 接口不支持 --skills |
🟡 | Phase 4 先验证 AIAgent(model=, base_url=, api_key=) 签名,必要时在 vendor 层薄封装 |
| Cloud Gateway JWT 链路未通 | 🟠 | Phase 4 可先保留 vendor TUI 路径作为 fallback,Gateway 通后再切 |
| 改造期间 _vendor 快照落后于 hermes 上游 | 🟢 | 改造不碰 _vendor,与 vendor 同步纪律正交 |
回退策略:每个 Phase 独立,可按 Phase 回退。recorder.py / tunnel.py / managed_mcp.py 在 git 历史中保留,任一 Phase 失败可 revert 对应 commit。
七、决策记录
| # | 决策 | 理由 |
|---|---|---|
| D1 | 选拆 recorder 为第一刀 | 双工模式是设计文档 §Phase4 的核心卖点,当前代码层面不可用,价值最高 |
| D2 | 用回调注入替代全局变量 | 消除 health ⇄ tunnel 循环耦合,单向数据流 |
| D3 | chat/ask 走 run_agent 而非 vendor TUI | 修正铁律 A(决策权归云端),vendor TUI 的 sys.exit() 和 7 个全局变量使其不可当函数调用 |
| D4 | 不把 CLI 变成 SSE 无状态原子 | CLI 是执行节点,依赖 Cloud Gateway/ASR,连接态必须存在 |
| D5 | 保留 vendor cli.py 作为 --offline 备选 |
铁律 C(断开即自治)要求离线模式可用 |
| D6 | pipelines/ 目录而非 infra/ | MindOS_CLI 是独立包,不依赖 hermes-overlay;pipelines/ 是自包含的无状态层 |
| D7 | 执行顺序调整为 3→1→2→4 | Phase 2 的 tunnel_session 需 import Phase 3 的 ToolProxy,先做 3 解除依赖 |
| D8 | pipx 自动更新检测安装方式 | pip install --upgrade 不影响 pipx 隔离 venv;检测 sys.executable 路径含 pipx/venvs → pipx reinstall |
| D9 | 防降级保护 | 服务器 versions.json 未同步时,_compare_versions 阻止本地被降级 |
| D10 | cli_info 快速路径工具 | Bridge 握手时已缓存 capability_report(含 version),LLM 查版本直接从缓存返回(0ms vs 10s Tunnel 往返),不调 cli_terminal 试探 |
| D11 | handler 复用主 event loop | set_bridge 捕获 _main_loop,handler 用 run_coroutine_threadsafe 替代每次新建 ThreadPoolExecutor + asyncio.run |
八、实施过程中发现并修复的 Bug
以下 bug 在改造过程中暴露,不属于原 SPEC 范围但必须修复:
| # | 文件 | Bug | 修复 | Commit |
|---|---|---|---|---|
| 1 | pyproject.toml |
缺 [tool.setuptools.package-data],VENDOR_COMMIT 未打包进 pipx |
声明 mindcli = ["_vendor/VENDOR_COMMIT", "_vendor/**/*"] |
bcf586b |
| 2 | health.py _get_vendor_commit() |
f.read().strip() 返回整个文件内容而非 commit hash |
改为逐行 commit: 前缀解析 |
2075fe6 |
| 3 | capability.py _get_vendor_commit() |
同上(上报给 bridge 的 vendor 字段是多行文本) | 同上对齐 | 37a26ac |
| 4 | health.py / capability.py |
读 HERMES_COMMIT(文件不存在),实际文件名 VENDOR_COMMIT |
统一为 VENDOR_COMMIT |
aecd81e |
| 5 | cli.py mind update |
pip install --upgrade 不影响 pipx 隔离 venv;裸 pip 在 venv PATH 里找不到 |
检测 pipx → pipx reinstall;非 pipx → sys.executable -m pip |
aecd81e |
| 6 | cli.py mind update |
无版本比较,服务器 versions.json 未同步时误降级 | _compare_versions 防降级保护 |
aecd81e |
九、Cloud→CLI 工具调用链路优化
9.1 问题
用户在云脑问"当前连接的 mindCLI 是什么版本"时,LLM 做 3 次 cli_terminal 工具调用试探(which mindcli / pip show / ls ~/.hermes/),每次往返 7-10s,总耗时 30s+ 逼近 300s 超时。
9.2 链路分析与超时层次
用户在云脑提问
│ Nginx proxy_read_timeout = 3600s
▼
BaseSSEServer._handleChat()
│ await _runChat(...) → _run_agent_task(fn, userId, chatId)
│ asyncio.wait_for(timeout=MINDOS_CHAT_TIMEOUT_SECONDS=300)
│ loop.run_in_executor → 同步线程池执行 agent.run_conversation()
▼
AIAgent.run_conversation() max_iterations=90
│ LLM 决定调 cli_terminal 工具
▼
cli_tunnel_tool._handler(args)
│ ★ 旧:每次新建 ThreadPoolExecutor + asyncio.run(临时 loop)
│ ★ 新:run_coroutine_threadsafe(coro, _main_loop)(复用主 loop)
▼
MindCLIBridge.dispatch_tool_call()
│ asyncio.wait_for(future, timeout=TOOL_CALL_TIMEOUT=120)
│ WebSocket → CLI → subprocess → 回流
▼
ToolProxy.execute() subprocess timeout=30s
9.3 三层修复
| 层 | 改动 | 文件 | 效果 |
|---|---|---|---|
| 可观测性 | per-call 耗时日志 + _call_stats + _run_agent_task 记录对话总耗时/调用次数 |
mindcli_bridge.py / sse_server.py |
每次对话后日志可见耗时分解 |
| 快速路径 | cli_info 工具直接从 Bridge _capabilities 缓存返回,不走 Tunnel |
cli_tunnel_tool.py |
"CLI 是什么版本"类查询 0ms 返回(vs 10s×3 次) |
| handler 复用主 loop | set_bridge 捕获 _main_loop,run_coroutine_threadsafe 替代临时 asyncio.run |
cli_tunnel_tool.py |
消除临时 event loop 创建/销毁开销 |
9.4 预期效果
| 场景 | 改造前 | 改造后 |
|---|---|---|
| "CLI 是什么版本?" | LLM 调 3 次 cli_terminal,~30s |
LLM 调 1 次 cli_info(缓存命中),~3-5s |
任意 cli_terminal 调用 |
每次新建临时 loop,~50-100ms 额外开销 | 复用主 loop,~5-10ms |
9.5 服务器端日志格式(改造后)
[Bridge] 能力报告: userId=11c1cece, cli_version=0.2.1, vendor=16f9d020, platform=Darwin, tools=6, mcp=0
[Bridge] → 11c1cece: cli_terminal (id=ab12cd34)
[Bridge] ← 11c1cece: cli_terminal 完成 (id=ab12cd34, 120ms)
[MindOSSSEServer] Chat 完成 userId=11c1cece chatId=chat_xxx 耗时=9.4s 工具调用=1次
附录:改造前后代码对照
A.1 录音:单例 → 多实例
# ═══ 改造前:recorder.py ════════════════════════════
_recorder: "SystemRecorder | None" = None
def get_recorder() -> "SystemRecorder":
global _recorder
if _recorder is None:
_recorder = SystemRecorder()
return _recorder
class SystemRecorder:
async def start(self, source, ws_ep, chat_id, on_text):
if self._running: # ← 互斥
return {"error": "录音已在进行中"}
# ...
# ═══ 改造后:pipelines/audio_capture.py ════════════
async def capture(
source: str,
ws_endpoint: str,
on_text: Callable[[str], None],
) -> CaptureHandle:
"""启动一路采集,返回独立句柄。可多实例并存。"""
handle = CaptureHandle(source, ws_endpoint, on_text)
await handle._start()
return handle
class CaptureHandle:
"""一路音频采集的句柄。生命周期由调用方管理。"""
async def stop(self) -> dict: ...
def status(self) -> dict: ...
A.2 Tunnel:全局变量 → 回调注入
# ═══ 改造前:tunnel.py ══════════════════════════════
_tunnel_client = TunnelClient()
class TunnelClient:
async def _connect(self):
# ...
from mindcli.health import set_tunnel_status # ← 反向耦合
set_tunnel_status("connected", len(tools))
# ═══ 改造后:pipelines/tunnel_session.py ═══════════
async def connect(
url: str,
jwt: str,
on_dispatch: Callable,
on_status: Callable, # ← 状态变更通过回调通知
) -> TunnelHandle:
handle = TunnelHandle(url, jwt, on_dispatch, on_status)
await handle._start()
return handle
# health.py 调用方
_tunnel_handle = await connect(
url, jwt,
on_dispatch=self._dispatch_tool_call,
on_status=lambda s, t=0: (_tunnel_status.__set_name__(...)) # 自己的状态自己写
)
A.3 chat/ask:vendor TUI → run_agent headless
# ═══ 改造前:cli.py ════════════════════════════════
@main.command()
def chat(model, skills, resume):
from cli import main as hermes_main # ← _vendor/cli.py 10000 行 TUI
hermes_main(**kwargs) # ← sys.exit() 杀进程
# ═══ 改造后:cli.py ════════════════════════════════
@main.command()
def chat(model, skills, resume):
from run_agent import AIAgent # ← headless
agent = AIAgent(
model=model or None,
base_url=_get_cloud_gateway_url(),
api_key=_get_jwt(),
)
agent.run_interactive(skills=skills, resume=resume)
最后更新:2026-07-01 v1.1(已实施)