我是张大鹏,做了十多年人工智能,带过不少项目。说实话,最难的不是让 AI 变聪明,是让承载 AI 的代码能持续维护。上个月我重构了日志系统,这次我要动更大的手术:把项目从一个平铺的单体结构,改造成六边形架构。
一、一个让我夜不能寐的文件夹
先说一个场景。
上周学生反馈了一个 bug:某个 Agent 任务执行到一半卡住了。我按经验去排查------llmcore.py 里有什么问题?打开文件,1568 行。往下翻,ga.py,784 行。再往下,main.py,2635 行。
这不是代码,这是三座大山。
三个文件加起来近 5000 行,而且互相引用:
agentmain.py ←→ ga.py ←→ agent_loop.py ←→ llmcore.py
↕
main.py
想改一个文件?你得先看懂另外四个。想加一个新功能?你得知道在哪一层加。想换一个 LLM 提供商?------llmcore.py 里 Claude、OpenAI、NativeClaude 全部写在一起,牵一发而动全身。
更让人头疼的是,连工具函数都混在一起:
ga.py------ 既管文件读写,又管浏览器控制,还管代码执行,中间还夹着 Agent 的工具调度逻辑llmcore.py------ API 请求、SSE 流解析、消息格式转换、会话管理,全在一个文件里agent_loop.py------ Agent 执行循环、BaseHandler、StepOutcome,好的坏的都在一起
这不是这个项目独有的问题,这是所有"从原型长起来"的项目都逃不过的宿命------先让它跑起来,再考虑让它健康。问题在于,项目已经跑到第二年了,"让它健康"这件事不能再拖了。
二、我选择了一个"激进但安全"的方案
在动手之前,我对比了几种架构方案:
| 方案 | 核心理念 | 工作量 | 风险 | 长期价值 |
|---|---|---|---|---|
| 渐进模块化 | 拆巨石文件,不动结构 | 2-3天 | 低 | 中 |
| 分层架构 | 三层划分:展示/应用/领域 | 1-2周 | 中 | 高 |
| 六边形架构 | 端口-适配器,核心与技术隔离 | 3-4周 | 高 | 最高 |
我的直觉想选方案一------风险最低,改起来最快。但我问了自己一个问题:"一年后回头看,我希望当初选了哪个方案?" 答案是六边形架构。
理由很简单:如意Agent 需要同时支持多个 LLM 提供商、多个前端界面(桌面/GUI/CLI)、多种记忆存储方式。六边形架构正好解决这个问题------把核心业务逻辑放在最中间,周围用"端口"定义接口,用"适配器"对接具体技术实现。
但风险确实高。所以我选了一套"绞杀藤(Strangler Fig)"策略:
不推倒重来,不中断服务。每改一块,老的那块就变成薄薄的一层转发,新的代码在新位置健康生长。
这套策略有四条铁律:
| 规则 | 内容 | 为什么 |
|---|---|---|
| 每阶段可独立合并 | 不存在"改到一半跑不起来"的状态 | 降低风险,随时可以停下来 |
| 每阶段测试通过 | pytest tests/ 是硬门禁 |
防止"你以为改好了"的幻觉 |
| 旧文件变薄转发 | from ga import X → 仍然可用,但 X 在 src/ 里 |
向后兼容,逐步替代 |
| 端口先行 | 先定义接口,再实现适配器 | 确保边界清晰,不改核心逻辑 |
三、第一阶段:从"最小单元"开刀
按照架构计划,第一阶段的目标是------提取核心数据模型。
为什么先从数据模型开始?因为它们是依赖图的"叶子节点"------不依赖任何东西,所有人都依赖它们。
我选了三个模型作为突破口:
| 模型 | 原来在哪 | 新位置 |
|---|---|---|
StepOutcome |
agent_loop.py |
src/core/model/step_outcome.py |
MockResponse |
llmcore.py |
src/core/model/llm_response.py |
MockToolCall / MockFunction |
llmcore.py |
src/core/model/llm_response.py |
这三个模型有一个共同特点:纯数据容器 。StepOutcome 是一个 dataclass,MockResponse 是一个简单的响应包装器。它们不调用 API、不读写文件、不涉及任何外部系统------提取它们基本上就是把代码从老地方"搬"到新地方,不改变任何行为。
TDD 流程
这次改造我严格遵循了 TDD(测试驱动开发):
第一步:写测试,确认失败(🔴 Red)
我先为 StepOutcome 写了 9 个测试:
python
# tests/core/unit/test_step_outcome.py
class TestStepOutcome:
def test_is_dataclass(self):
assert dataclasses.is_dataclass(StepOutcome)
def test_creation_with_data_only(self):
outcome = StepOutcome(data="hello")
assert outcome.data == "hello"
assert outcome.next_prompt is None
assert outcome.should_exit is False
def test_creation_with_all_fields(self):
outcome = StepOutcome(data={"key": "value"}, next_prompt="continue", should_exit=True)
assert outcome.data == {"key": "value"}
assert outcome.next_prompt == "continue"
assert outcome.should_exit is True
# ... 更多边缘情况测试
运行结果:ModuleNotFoundError------模型文件还不存在。这正是我想要的,测试正确地失败了。
第二步:实现模型,让测试通过(🟢 Green)
核心模型代码非常简洁:
python
# src/core/model/step_outcome.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class StepOutcome:
"""Result of a single agent execution step."""
data: Any
next_prompt: str | None = None
should_exit: bool = False
python
# src/core/model/llm_response.py
class MockResponse:
def __init__(self, thinking, content, tool_calls, raw, stop_reason="end_turn"):
self.thinking = thinking
self.content = content
self.tool_calls = tool_calls
self.raw = raw
self.stop_reason = "tool_use" if tool_calls else stop_reason
运行新测试:26 passed,0 failed。
第三步:加向后兼容,重构老文件(🔁 Refactor)
最微妙的一步------让旧代码继续可用。做法是在老文件里加一个 import 转发:
python
# agent_loop.py --- 删掉了 StepOutcome 类定义,替换为:
from src.core.model.step_outcome import StepOutcome # re-export
# llmcore.py --- 删掉了三个 Mock 类定义,替换为:
from src.core.model.llm_response import MockFunction, MockToolCall, MockResponse # re-export
这样做的结果是:
from agent_loop import StepOutcome→ 仍然可用from llmcore import MockResponse→ 仍然可用from src.core.model import StepOutcome→ 新的标准路径
两条路径指向同一个类,不存在两份代码的同步问题。
四、成果量化
改造完成后,我跑了全量验证:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 测试总数 | 230 pass / 7 fail | 256 pass / 7 fail | +26 新测试,0 回归 |
| ruff 新代码 | N/A | All checks passed | ✅ |
| 旧导入路径 | 正常 | 正常(验证类身份一致) | ✅ |
| 新建文件 | 0 | 8 个 | 包结构就位 |
| 修改文件 | 0 | 3 个 | 老文件变薄转发层 |
目录结构的变化:
GenericAgent/
├── src/ ← ★ 新增:源码目录
│ └── core/
│ └── model/
│ ├── __init__.py
│ ├── step_outcome.py ← StepOutcome dataclass
│ └── llm_response.py ← MockResponse / MockToolCall / MockFunction
├── agent_loop.py ← 转发层(-7 行)
├── llmcore.py ← 转发层(-22 行)
└── tests/core/unit/ ← ★ 新增:领域模型测试
├── test_step_outcome.py ← 9 个测试
└── test_llm_response.py ← 17 个测试
七个失败的测试是已有的时区问题,和我的改动无关。
五、下一个里程碑
第一阶段只是开始。我接下来的计划是这样的:
| 阶段 | 内容 | 预计工时 | 状态 |
|---|---|---|---|
| ① 核心数据模型 | StepOutcome / MockResponse 提取 | 0.5d | ✅ 已完成 |
| ② 文件系统和代码执行适配器 | code_run / file_* → 适配器 | 2d | ⏳ |
| ③ LLM 适配器 | llmcore.py 拆分为模块化 Session | 3-4d | |
| ④ 浏览器和记忆适配器 | web_scan / memory 提取 | 2d | |
| ⑤ Agent Service | GenericAgentHandler 注入化 | 3-4d | |
| ⑥ 装配层 + DI | Container,配置管理 | 2d | |
| ⑦ CLI + 桌面适配器 | main.py / agentmain.py 变薄 | 2-3d | |
| ⑧ 清理收尾 | 移除转发层 | 1d |
整个计划大约 16-19 天,每阶段都可独立合并和验证。
总结
| 维度 | 内容 |
|---|---|
| 核心思路 | 六边形架构 + Strangler Fig 绞杀藤迁移策略,每阶段可逆可验证 |
| 第一阶段 | 提取 StepOutcome / MockResponse 等核心数据模型到 src/core/model/ |
| 开发方式 | TDD(Red → Green → Refactor),26 个新测试全覆盖 |
| 向后兼容 | 老文件变薄转发层,所有现有导入路径不受影响 |
| 迁移铁律 | 不推倒重来、每阶段测试通过、每阶段可独立合并 |
参考资料:
- Alistair Cockburn --- Hexagonal Architecture
- Strangler Fig Application --- Martin Fowler
- 如意Agent 项目源码
- 如意Agent日志系统重构 ------ 从print大海捞针到结构化可观测性栈
作者 :张大鹏
日期:2026-05-03