当 jarvis --help 显示 13 个子命令全部正常注册时,我终于确认:这次重构没有翻车。
这是 Jarvis-Agent 的第 2.5 阶段——一个没有新功能的版本。听起来无聊?但这可能是整个项目中最重要的一次迭代。
为什么要停下来打扫?
Phase 2 写完后,我做了一次完整的代码审查。结果让我有点不好意思:
| 问题 | 严重程度 | 描述 |
|---|---|---|
| 🔴 线程安全 | P0 | _recent_changes 在 watchdog 线程和 async 主循环之间裸读写 |
| 🔴 死代码 | P0 | 7 个文件完全没有被引用,还占着工程目录 |
| 🟡 CLI 巨石 | P1 | cli.py 膨胀到 1909 行,什么逻辑都往里塞 |
| 🟡 遗留模块 | P1 | Phase 0 的 config.py、main.py 还躺在 src/ 里 |
看完这份清单,我意识到一个事实:
如果不在 Phase 3 之前把这些债还掉,后面每加一个功能都要踩一次坑。
于是我决定:Phase 2.5,不写新功能,只还债。
🔒 第一刀:线程安全
这个 bug 最隐蔽,也最危险。
JarvisEventHandler 是 watchdog 的文件事件处理器。它有一个 _recent_changes 列表,用来收集文件变化。问题是:watchdog 的回调跑在独立线程里,而主循环是 asyncio。两边同时读写同一个列表,没有任何保护。
你可能说"Python 有 GIL,列表操作是原子的"——确实,list.append() 是原子的,但 get_and_clear_changes() 里是先读后清,这个组合操作不是原子的:
# ❌ 修复前:两步操作之间可能被插入
def get_and_clear_changes(self):
changes = self._recent_changes.copy() # 读
self._recent_changes.clear() # 清 ← 读和清之间可能有新写入
return changes
修复很简单,加一把锁:
import threading
class JarvisEventHandler(FileSystemEventHandler):
def __init__(self):
self._recent_changes = []
self._lock = threading.Lock() # ✅ 新增
def _record_change(self, event_type, path):
with self._lock: # ✅ 写入加锁
self._recent_changes.append({...})
def get_and_clear_changes(self):
with self._lock: # ✅ 读+清原子化
changes = self._recent_changes.copy()
self._recent_changes.clear()
return changes
三行代码,消除一个 race condition。这种 bug 可能跑一万次都不出问题,但出一次就是数据丢失。
🗑️ 第二刀:清除死代码
接下来是最爽的部分——删代码。
我用 grep -r 搜索每个模块的导入和引用情况,找出了七个完全没有被使用的文件:
| 文件 | 来历 | 行数 | 状态 |
|---|---|---|---|
src/main.py | Phase 0 的 FastAPI 入口 | 180 行 | 🗑️ 已删除 |
src/config.py | Phase 0 的 Pydantic Settings | 120 行 | 🗑️ 已删除 |
src/llm/client.py | 早期直调 DeepSeek/Claude API | 200 行 | 🗑️ 已删除 |
src/memory/database.py | Phase 1 的纯 SQLite 方案 | 250 行 | 🗑️ 已删除 |
src/memory/models.py | 旧数据模型 | 80 行 | 🗑️ 已删除 |
src/proactive/scheduler.py | 未实现的定时调度 | 150 行 | 🗑️ 已删除 |
src/proactive/__init__.py | 空包 | 5 行 | 🗑️ 已删除 |
另外还删掉了 cli.py 中的 serve 命令——那是 Phase 0 遗留的 FastAPI serve 逻辑,从来没有被调用过。
总计删除约 1000 行代码。删完之后项目目录清爽多了:没有的引用的模块全部消失,src/proactive/ 整个目录都不存在了。
删代码比写代码更需要勇气——你得确认每一行真的没人用。但删完之后的轻松感,无与伦比。
📦 第三刀:CLI 模块化
这是工作量最大的部分。
cli.py 是整个项目的 CLI 入口,承载了 13 个子命令、聊天循环、Tab 补全、流式输出、守护进程管理……全部堆在一个文件里,1909 行。
找一个函数?先 Ctrl+F。改一个命令?担心影响其他命令。这不是一个可以继续进化的架构。
分拆策略
我按职责边界把 cli.py 拆成了 6 个模块:
src/cli/ # 📦 从 1 个文件 → 6 个模块
├── __init__.py # 141 行 app 定义 + main callback
├── common.py # 180 行 常量、路径、状态查询
├── chat.py # 335 行 聊天循环、补全器、streaming
├── daemon_cmds.py # 272 行 start/rest/status
├── memory_cmds.py # 310 行 recall/think/insights
└── explore_cmds.py # 415 行 init/explore/projects/discoveries/skills
关键设计决策
1. 保持入口不变
pyproject.toml 里的入口是 jarvis = "src.cli:app"。把 cli.py 变成 cli/ 包之后,__init__.py 导出 app 对象,入口零改动。所有用户的 jarvis 命令无感升级。
2. 共享状态下沉
JARVIS_HOME、VERSION、get_config_path() 这些常量和工具函数,下沉到 common.py,其他模块 from .common import 即可。
3. 循环依赖预防
聊天模块可能需要调用 daemon 命令(比如用户在聊天中输入 /start),daemon 模块不需要知道聊天的存在。依赖方向是单向的:
__init__.py → 所有子模块
chat.py → common.py, daemon_cmds.py, memory_cmds.py, explore_cmds.py
daemon_cmds.py → common.py
memory_cmds.py → common.py
explore_cmds.py → common.py
Before vs After
| 指标 | Before | After |
|---|---|---|
| 文件数 | 1 个 | 6 个 |
| 最大文件 | 1909 行 | 415 行 |
| 平均文件 | 1909 行 | 276 行 |
| 找函数 | Ctrl+F 全文搜 | 按职责打开对应文件 |
| 改命令 | 担心影响全局 | 修改对应模块即可 |
🔧 附赠:两个新功能
趁着重构,顺手实现了两个一直想做但没做的小功能:
jarvis projects:真正能用了
Phase 1 写了这个命令的签名,但实现是空的。现在它会读取 discoveries.json,提取所有被发现的项目:
$ jarvis projects
📂 已发现的项目 (3个)
1. 📦 Jarvis-Agent (python)
路径: /Users/polly/.../Jarvis-Agent
发现时间: 2026-02-05 02:39
2. 📦 Master-Translator-MCP-Server (python)
路径: /Users/polly/.../Master-Translator-MCP-Server
发现时间: 2026-02-05 02:40
3. 📦 www.polly.com (zola)
路径: /Users/polly/.../www.polly.com
发现时间: 2026-02-05 02:41
jarvis init:自动创建人格
初始化时自动调用 MemoryWriter.init_persona(),在 ~/.jarvis/memory/persona.md 创建人格骨架文件。Jarvis 的"灵魂"从第一次启动就开始生长。
🫀 PID 管理:优雅地停止
Phase 2 有一个恼人的 bug:jarvis -r(停止 daemon)不生效。
根本原因是 daemon 跑在子进程里,-r 命令只是设置了一个状态文件,但 daemon 的 asyncio.sleep(300) 不会被打断——它要等 5 分钟才能检查到停止信号。
修复方案:PID 文件 + SIGTERM 信号。
# 启动时写入 PID
pid_path = get_pid_path()
pid_path.write_text(str(daemon_process.pid))
# 停止时发送 SIGTERM
def _do_rest():
pid = int(pid_path.read_text())
os.kill(pid, signal.SIGTERM)
Daemon 端注册信号处理器,收到 SIGTERM 后立即取消 sleep:
async def run_daemon():
loop = asyncio.get_event_loop()
stop_event = asyncio.Event()
def handle_signal():
stop_event.set()
loop.add_signal_handler(signal.SIGTERM, handle_signal)
while not stop_event.is_set():
await heartbeat()
# 可被信号打断的等待
try:
await asyncio.wait_for(stop_event.wait(), timeout=300)
except asyncio.TimeoutError:
pass # 正常超时,继续循环
效果:jarvis -r 后 daemon 立即停止,不再需要等待下一个心跳周期。
📊 Phase 2.5 总结
最终的代码统计:
| 指标 | Phase 2 结束 | Phase 2.5 结束 | 变化 |
|---|---|---|---|
| 总代码量 | ~4800 行 | 3794 行 | -1000 行 |
| 文件数 | ~20 个 .py | 14 个 .py | -6 个 |
| 最大文件 | 1909 行 (cli.py) | 666 行 (daemon.py) | -65% |
| 死代码 | 7 个文件 | 0 | ✅ |
| 线程安全问题 | 1 个 | 0 | ✅ |
Git 统计:19 files changed, 1742 insertions(+), 2512 deletions(-)
删掉的比写的多——这正是一次成功重构的标志。
💭 反思:为什么 Phase 2.5 很重要
软件开发有个说法:"代码的熵增是不可逆的"。
每次加功能,代码都会变复杂一点。如果不定期做清理,复杂度会指数增长,直到你不敢再动它。
Jarvis-Agent 还在进化的早期。Phase 3(工具调用)和 Phase 4(Skill 自生成)都是大工程。如果带着 1909 行的巨石 CLI 和七个死代码文件去做这些,每一步都会更难。
Phase 2.5 就像是一次「深呼吸」——停下来,整理好行装,再出发。
一个有趣的数字
整个 Phase 2.5 从开始到结束,大约花了 3 小时。但如果不做这次清理,这些技术债会在接下来的每个 Phase 中反复出现,累计浪费的时间可能是 30 小时。
还债的最佳时机,永远是现在。
🔮 Phase 3 预告
Phase 2.5 让 Jarvis 的代码库变得干净、模块化。接下来,Phase 3 的目标是让 Jarvis 学会动手做事:
- 🦾 Tool Registry:工具注册与发现
- 🔌 MCP 集成:复用已有的 MCP Server
- 📋 任务规划:分解复杂任务,逐步执行
从"能想"到"能做"——这才是 Agent 真正的进化方向。
相关文章:

