第 8 章:实战 -- 项目架构设计
前七章我们一块一块地学了 SDK 的各个零件。从这一章开始,我们要把这些零件组装成一台真正的机器 -- MiniClaw,一个迷你版 CLI Agent 助手。
MiniClaw 是什么
你听说过 NanoClaw 吗?它是一个轻量级的开源 AI 助手,用 TypeScript 写的,大约 3900 行代码。它能接入 WhatsApp、支持容器隔离、有记忆系统,功能还挺齐全的。
MiniClaw 就是我们自己造的"Python 版迷你 NanoClaw"。但我们不走大而全的路线,我们走小而完整的路线:
| 对比项 | NanoClaw | MiniClaw |
|---|---|---|
| 语言 | TypeScript | Python |
| 交互方式 | WhatsApp + Web | CLI(命令行) |
| 底层 | 直接调 API | Claude Agent SDK |
| 代码量 | ~3900 行 | ~500 行(目标) |
| 定位 | 可部署的产品 | 可学习的教程项目 |
MiniClaw 的设计理念是:小到能完全理解。
想象你去宜家买了个书架,打开包装,发现零件有 200 个,说明书有 40 页 -- 你大概会先叹口气。但如果零件只有 20 个,说明书 2 页 -- 你一杯咖啡的功夫就能装好,而且完全明白每个零件是干什么的。
MiniClaw 就是那个 20 个零件的书架。它虽然小,但五脏俱全:
- 多轮对话(第 9 章)
- 记忆系统(第 9 章)
- 可扩展的工具生态(第 10 章)
- 安全防护(第 10 章)
- 定时任务(第 11 章)
- 多 Agent 协作(第 11 章)
本章我们先搭骨架 -- 把项目结构设计好,把认证和配置这两个"基础设施"先跑起来。
架构设计
MiniClaw 采用经典的分层架构。如果你写过 Web 应用,这套路你应该很熟悉:上层调下层,每层只管自己的事。
┌─────────────────────────────────┐
│ CLI 交互层 (cli.py) │ <-- 用户看到的界面:输入、输出、美化
├─────────────────────────────────┤
│ 对话引擎 (engine.py) │ <-- 核心:ClaudeSDKClient 封装
├─────────────────────────────────┤
│ 工具层 (tools/) │ <-- SDK MCP 自定义工具
├─────────────────────────────────┤
│ 存储层 (memory + config) │ <-- SQLite 记忆 + JSON 配置
└─────────────────────────────────┘
打个比方:MiniClaw 就像一家小餐馆。
- CLI 交互层是前厅 -- 服务员接待客人、传菜、收银。客人(用户)只跟这层打交道。
- 对话引擎是后厨 -- 厨师(Claude)在这里真正干活。前厅把点单传过来,后厨做好菜传回去。
- 工具层是厨房里的各种厨具 -- 刀、锅、烤箱。厨师需要什么工具,就从这里拿。
- 存储层是仓库 -- 食材(记忆数据)和菜单(配置)都存在这里。
每层的职责:
| 层 | 文件 | 职责 |
|---|---|---|
| CLI 交互层 | cli.py |
读取用户输入、格式化输出、命令解析 |
| 对话引擎 | engine.py |
管理 ClaudeSDKClient 生命周期、处理多轮对话 |
| 工具层 | tools/ |
注册和管理 MCP 工具、内置工具实现 |
| 存储层 | memory.py + config.py |
SQLite 记忆存储、JSON 配置读写 |
为什么要分层?因为你可以单独替换任何一层。比如:
- 想把 CLI 换成 Web 界面?只改
cli.py,其他不动。 - 想把记忆从 SQLite 换成 Redis?只改
memory.py。 - 想加个新工具?在
tools/里加个文件就行。
项目目录结构
miniclaw/
├── __init__.py # 版本信息
├── __main__.py # 入口: python -m miniclaw
├── auth.py # 认证: API Key / 订阅自动检测
├── config.py # 配置: ~/.miniclaw/config.json
├── cli.py # CLI 交互层 (第 9 章实现)
├── engine.py # 对话引擎 (第 9 章实现)
├── memory.py # 记忆系统 (第 9 章实现)
├── tools/ # 工具目录 (第 10 章实现)
│ ├── __init__.py
│ ├── builtin.py # 内置工具:文件摘要、代码分析等
│ └── registry.py # 工具注册中心
├── scheduler.py # 定时任务 (第 11 章实现)
└── agents.py # 多 Agent 协作 (第 11 章实现)
你可能会问:为什么不一开始就把所有文件都写好?
因为软件开发有个原则叫 YAGNI -- "You Aren't Gonna Need It"(你现在用不到它)。我们一章一章往里填代码,每一章都只加当前需要的东西。这样你在任何一个时间点看到的代码,都是完整可运行的,而不是一堆写了一半的空壳。
本章我们只实现三个文件:
| 文件 | 本章实现 | 说明 |
|---|---|---|
__init__.py |
完整 | 版本信息,一行搞定 |
__main__.py |
完整 | 项目入口,加载配置 + 检测认证 |
auth.py |
完整 | 双模式认证检测 |
config.py |
完整 | 配置管理 |
先看最简单的两个文件:
miniclaw/__init__.py -- 只做一件事,声明版本号:
"""MiniClaw - 迷你版 AI Agent 助手。
基于 Claude Agent SDK 构建的 CLI Agent 助手,
支持多轮对话、记忆系统、自定义工具等完整功能。
"""
__version__ = "0.1.0"
miniclaw/__main__.py -- 项目入口,python -m miniclaw 就跑这个文件:
"""MiniClaw 入口文件。
运行方式: python -m miniclaw
"""
import asyncio
import sys
from .auth import detect_auth_mode
from .config import load_config
def print_banner():
"""打印启动横幅。"""
print("=" * 50)
print(" MiniClaw v0.1.0 - 迷你 AI Agent 助手")
print("=" * 50)
async def main():
"""主函数。"""
print_banner()
# 1. 加载配置
config = load_config()
print(f"\n配置文件: {config.config_path}")
# 2. 检测认证模式
auth = detect_auth_mode()
if auth.mode == "none":
print(f"\n错误: {auth.error}")
print("请设置 ANTHROPIC_API_KEY 环境变量或运行 claude login")
sys.exit(1)
print(f"认证模式: {auth.mode}")
if auth.mode == "api_key":
print(f"API Key: {auth.display_key}")
else:
print(f"CLI 路径: {auth.cli_path}")
# 3. 显示当前配置摘要
if config.verbose:
print(f"\n当前配置:")
print(f" 模型: {config.model}")
print(f" 最大轮数: {config.max_turns}")
print(f" 预算上限: ${config.max_budget_usd}")
print(f" 工具: {'启用' if config.tools_enabled else '禁用'}")
print(f" 记忆: {'启用' if config.memory_enabled else '禁用'}")
print("\n项目骨架已就绪!后续章节将添加完整功能。")
print(" - 第 9 章: 对话引擎 + 记忆系统")
print(" - 第 10 章: 工具生态")
print(" - 第 11 章: 高级特性")
if __name__ == "__main__":
asyncio.run(main())
入口文件的逻辑很清晰:打印横幅 → 加载配置 → 检测认证 → 显示状态。接下来看两个重头戏:认证检测和配置管理。
双模式认证
还记得第 3 章讲的两种认证方式吗?API Key 模式和订阅模式。MiniClaw 需要两种都支持,而且要自动检测,不用用户手动选。
检测逻辑就像你进一家酒店:
- 先看你有没有带房卡(API Key) -- 有就直接进。
- 没房卡?看你有没有在前台登记过(
claude login) -- 登记过也能进。 - 都没有?那对不起,请先办理入住。
用流程图表示:
开始
│
▼
检查 ANTHROPIC_API_KEY 环境变量
│
├── 有值 ──→ API Key 模式 ✓
│
└── 没有
│
▼
检查 claude 命令是否安装
│
├── 没安装 ──→ 报错,提示用户配置 ✗
│
└── 已安装
│
▼
运行 claude auth status 验证登录
│
├── loggedIn: true ──→ 订阅模式 ✓
│
└── 未登录/失败 ──→ 报错,提示运行 claude login ✗
这个逻辑实现在 auth.py 中。核心代码很短:
from miniclaw.auth import detect_auth_mode
auth = detect_auth_mode()
print(auth.mode) # "api_key" 或 "subscription" 或 "none"
print(auth.display_key) # "sk-ant-api03-xxxx..." (脱敏显示)
几个设计要点:
1. API Key 脱敏显示
永远不要把完整的 API Key 打印到屏幕上。MiniClaw 只显示前 12 个字符加省略号。这是安全基本功。
2. 优雅降级
检测失败不抛异常,而是返回 mode="none" 和一条错误信息。让调用方决定怎么处理。这比直接 raise 更灵活 -- 比如你可能想在 UI 上显示一个友好的提示,而不是一段冷冰冰的堆栈跟踪。
3. CLI 路径记录
订阅模式下,我们记录 claude 命令的完整路径。这在调试的时候很有用 -- 用户可能装了多个版本的 CLI,知道具体用的哪个能帮你快速定位问题。
完整代码 miniclaw/auth.py:
"""MiniClaw 认证检测模块。
支持两种认证模式:
1. API Key 模式 -- 通过 ANTHROPIC_API_KEY 环境变量
2. 订阅模式 -- 通过 claude login 登录状态
使用方法:
from miniclaw.auth import detect_auth_mode
auth = detect_auth_mode()
if auth.mode == "api_key":
print(f"使用 API Key: {auth.display_key}")
elif auth.mode == "subscription":
print(f"使用订阅模式, CLI 路径: {auth.cli_path}")
else:
print(f"认证失败: {auth.error}")
"""
import json
import os
import shutil
import subprocess
from dataclasses import dataclass
@dataclass
class AuthMode:
"""认证模式信息。
Attributes:
mode: 认证模式。"api_key" / "subscription" / "none"
api_key: 完整的 API Key(仅 api_key 模式有值)。
注意:不要把这个值打印到日志里!用 display_key 代替。
display_key: 脱敏后的 API Key,可以安全地显示(如 "sk-ant-api03-xxxx...")。
cli_path: claude 命令的完整路径(仅 subscription 模式有值)。
error: 错误信息(仅 none 模式有值)。
"""
mode: str # "api_key" | "subscription" | "none"
api_key: str = ""
display_key: str = ""
cli_path: str = ""
error: str = ""
def _mask_api_key(key: str) -> str:
"""将 API Key 脱敏,只保留前 12 个字符。
>>> _mask_api_key("sk-ant-api03-abcdef123456789")
'sk-ant-api03...'
>>> _mask_api_key("short")
'short...'
"""
# 前 12 个字符 + 省略号
visible_length = min(12, len(key))
return key[:visible_length] + "..."
def _check_api_key() -> AuthMode | None:
"""检查环境变量中是否有 API Key。
Returns:
有 Key 返回 AuthMode 对象,没有返回 None。
"""
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
if not api_key:
return None
return AuthMode(
mode="api_key",
api_key=api_key,
display_key=_mask_api_key(api_key),
)
def _check_cli_login() -> AuthMode | None:
"""检查 Claude CLI 是否已安装并登录。
检查逻辑:
1. shutil.which() 查找 claude 命令 -- CLI 是否安装?
2. 运行 claude auth status 验证 -- 是否真的登录了?
仅靠 shutil.which("claude") 只能说明 CLI 安装过,
不能证明用户执行了 claude login。所以还需要调用
claude auth status 来确认登录状态。
claude auth status 会返回 JSON:
{"loggedIn": true, "email": "...", ...} -- 已登录,退出码 0
{"loggedIn": false, ...} -- 未登录,退出码非 0
Returns:
检测到登录状态返回 AuthMode 对象,否则返回 None。
"""
# 第一步:CLI 是否安装?
cli_path = shutil.which("claude")
if not cli_path:
return None
# 第二步:是否登录了?
# 运行 claude auth status,检查返回的 JSON 中 loggedIn 是否为 true
try:
result = subprocess.run(
[cli_path, "auth", "status"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return None
# 解析 JSON,确认 loggedIn 为 true
status = json.loads(result.stdout)
if not status.get("loggedIn", False):
return None
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError):
# CLI 执行失败、超时、或返回的不是合法 JSON -- 都视为未登录
return None
return AuthMode(
mode="subscription",
cli_path=cli_path,
)
def detect_auth_mode() -> AuthMode:
"""自动检测当前可用的认证模式。
检测优先级:
1. ANTHROPIC_API_KEY 环境变量 (API Key 模式)
2. claude CLI 可用性 (订阅模式)
3. 都没有 -> 返回 mode="none"
Returns:
AuthMode 对象,包含检测到的认证信息。
使用示例:
auth = detect_auth_mode()
if auth.mode == "none":
print(f"错误: {auth.error}")
sys.exit(1)
# 根据认证模式构建 SDK 选项
if auth.mode == "api_key":
options = ClaudeAgentOptions(
env={"ANTHROPIC_API_KEY": auth.api_key}
)
else:
options = ClaudeAgentOptions() # 订阅模式不需要额外配置
"""
# 优先检查 API Key
result = _check_api_key()
if result is not None:
return result
# 其次检查 CLI 登录状态
result = _check_cli_login()
if result is not None:
return result
# 都没有找到
return AuthMode(
mode="none",
error="未找到可用的认证方式。请设置 ANTHROPIC_API_KEY 环境变量,或运行 'claude login' 登录。",
)
# ---- 直接运行此文件可以测试认证检测 ----
if __name__ == "__main__":
auth = detect_auth_mode()
print(f"认证模式: {auth.mode}")
if auth.mode == "api_key":
print(f"API Key: {auth.display_key}")
elif auth.mode == "subscription":
print(f"CLI 路径: {auth.cli_path}")
else:
print(f"错误: {auth.error}")
配置管理
每个像样的 CLI 工具都需要配置文件。MiniClaw 的配置存在 ~/.miniclaw/config.json:
{
"model": "sonnet",
"system_prompt": "你是 MiniClaw,一个友好的 AI 助手。用中文回答问题。",
"max_turns": 30,
"max_budget_usd": 1.0,
"tools_enabled": true,
"memory_enabled": true,
"verbose": false
}
配置管理的设计思路是默认配置 + 用户覆盖:
- 代码里写好一套默认配置,保证即使没有配置文件也能正常运行。
- 如果用户创建了配置文件,就用用户的值覆盖默认值。
- 用户只需要写自己想改的项,不需要把所有配置都写一遍。
就像你新买了一台手机 -- 出厂设置已经能用了,你只需要改你在意的那几项(比如壁纸、铃声)。
from miniclaw.config import load_config
config = load_config()
print(config.model) # "sonnet" (默认值)
print(config.max_budget_usd) # 1.0 (默认值)
print(config.config_path) # "~/.miniclaw/config.json"
可配置项一览
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
model |
str |
"sonnet" |
使用的模型 |
system_prompt |
str |
见默认值 | Claude 的角色设定 |
max_turns |
int |
30 |
最大对话轮数 |
max_budget_usd |
float |
1.0 |
单次对话预算上限(美元) |
tools_enabled |
bool |
True |
是否启用工具 |
memory_enabled |
bool |
True |
是否启用记忆系统 |
verbose |
bool |
False |
是否显示详细日志 |
首次运行自动创建
如果 ~/.miniclaw/ 目录和配置文件不存在,load_config() 会自动创建它们,并写入默认配置。这样用户第一次运行就有一个可编辑的配置文件模板,不用自己从零写起。
完整代码 miniclaw/config.py:
"""MiniClaw 配置管理模块。
配置文件存储在 ~/.miniclaw/config.json。
首次运行会自动创建目录和默认配置文件。
使用方法:
from miniclaw.config import load_config, save_config
# 加载配置(不存在则自动创建默认配置)
config = load_config()
print(config.model) # "sonnet"
print(config.max_budget_usd) # 1.0
# 修改并保存
config.model = "opus"
save_config(config)
"""
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
# ---- 默认配置 ----
# MiniClaw 的配置目录和文件路径
CONFIG_DIR = Path.home() / ".miniclaw"
CONFIG_FILE = CONFIG_DIR / "config.json"
# 默认系统提示词
DEFAULT_SYSTEM_PROMPT = "你是 MiniClaw,一个友好的 AI 助手。用中文回答问题,回答要简洁准确。"
# 默认配置值。用户只需要覆盖自己想改的项。
DEFAULT_CONFIG = {
"model": "sonnet",
"system_prompt": DEFAULT_SYSTEM_PROMPT,
"max_turns": 30,
"max_budget_usd": 1.0,
"tools_enabled": True,
"memory_enabled": True,
"verbose": False,
}
# ---- 配置数据类 ----
@dataclass
class MiniClawConfig:
"""MiniClaw 配置。
所有字段都有默认值,所以不传任何参数也能正常使用。
Attributes:
model: 使用的模型名称。比如 "sonnet"、"opus"、"haiku"。
system_prompt: Claude 的角色设定。会作为 ClaudeAgentOptions.system_prompt 传入。
max_turns: 单次对话的最大轮数。防止 Claude 跑飞。
max_budget_usd: 单次对话的预算上限(美元)。超了就停。
tools_enabled: 是否启用自定义工具。关掉后 Claude 只能纯聊天。
memory_enabled: 是否启用记忆系统。关掉后每次对话都是全新的。
verbose: 是否显示详细日志。调试时打开。
config_path: 配置文件的实际路径(只读,不会保存到文件中)。
"""
model: str = DEFAULT_CONFIG["model"] # type: ignore[assignment]
system_prompt: str = DEFAULT_CONFIG["system_prompt"] # type: ignore[assignment]
max_turns: int = DEFAULT_CONFIG["max_turns"] # type: ignore[assignment]
max_budget_usd: float = DEFAULT_CONFIG["max_budget_usd"] # type: ignore[assignment]
tools_enabled: bool = DEFAULT_CONFIG["tools_enabled"] # type: ignore[assignment]
memory_enabled: bool = DEFAULT_CONFIG["memory_enabled"] # type: ignore[assignment]
verbose: bool = DEFAULT_CONFIG["verbose"] # type: ignore[assignment]
# 配置文件路径(内部使用,不写入 JSON)
config_path: str = field(default=str(CONFIG_FILE), repr=True)
def to_dict(self) -> dict:
"""转换为字典,用于保存到 JSON 文件。
注意: config_path 不会包含在输出中,因为它不是用户配置。
"""
data = asdict(self)
# config_path 是运行时属性,不保存到文件
data.pop("config_path", None)
return data
# ---- 配置读写函数 ----
def _ensure_config_dir() -> None:
"""确保配置目录存在。如果不存在就创建它。"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def load_config(config_path: str | Path | None = None) -> MiniClawConfig:
"""加载配置文件。
加载逻辑:
1. 如果配置文件存在 -> 读取并与默认值合并
2. 如果不存在 -> 创建默认配置文件并返回默认配置
"合并"的意思是: 以默认值为底,用用户的值覆盖。
所以用户只需要写自己想改的项,没写的自动用默认值。
Args:
config_path: 自定义配置文件路径。默认为 ~/.miniclaw/config.json。
主要用于测试。
Returns:
MiniClawConfig 对象。
"""
# 确定配置文件路径
path = Path(config_path) if config_path else CONFIG_FILE
if path.exists():
# 读取用户配置
try:
with open(path, encoding="utf-8") as f:
user_config = json.load(f)
except (json.JSONDecodeError, OSError) as e:
# 配置文件损坏了?用默认值,但打个警告
print(f"警告: 配置文件读取失败 ({e}),使用默认配置。")
user_config = {}
# 默认值打底 + 用户值覆盖
merged = {**DEFAULT_CONFIG, **user_config}
return MiniClawConfig(
model=merged["model"],
system_prompt=merged["system_prompt"],
max_turns=merged["max_turns"],
max_budget_usd=merged["max_budget_usd"],
tools_enabled=merged["tools_enabled"],
memory_enabled=merged["memory_enabled"],
verbose=merged["verbose"],
config_path=str(path),
)
else:
# 首次运行,创建默认配置文件
_ensure_config_dir()
default_config = MiniClawConfig(config_path=str(path))
save_config(default_config)
return default_config
def save_config(config: MiniClawConfig) -> None:
"""保存配置到文件。
Args:
config: 要保存的配置对象。
"""
path = Path(config.config_path)
# 确保目录存在
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(
config.to_dict(),
f,
ensure_ascii=False, # 保留中文字符,不转义成 \uXXXX
indent=4, # 缩进 4 格,方便手动编辑
)
# ---- 直接运行此文件可以测试配置管理 ----
if __name__ == "__main__":
print("加载配置...")
cfg = load_config()
print(f"配置文件路径: {cfg.config_path}")
print(f"模型: {cfg.model}")
print(f"系统提示词: {cfg.system_prompt[:30]}...")
print(f"最大轮数: {cfg.max_turns}")
print(f"预算上限: ${cfg.max_budget_usd}")
print(f"工具: {'启用' if cfg.tools_enabled else '禁用'}")
print(f"记忆: {'启用' if cfg.memory_enabled else '禁用'}")
print(f"详细日志: {'启用' if cfg.verbose else '禁用'}")
print(f"\n配置字典: {json.dumps(cfg.to_dict(), ensure_ascii=False, indent=2)}")
跑起来看看
本章的代码已经可以运行了。在 miniclaw/ 目录的上级目录执行:
python -m miniclaw
如果你设置了 API Key,会看到类似这样的输出:
==================================================
MiniClaw v0.1.0 - 迷你 AI Agent 助手
==================================================
配置文件: /Users/你的用户名/.miniclaw/config.json
认证模式: api_key
API Key: sk-ant-api03-...
项目骨架已就绪!后续章节将添加完整功能。
- 第 9 章: 对话引擎 + 记忆系统
- 第 10 章: 工具生态
- 第 11 章: 高级特性
如果你用的是订阅模式(claude login 过了),会看到:
认证模式: subscription
CLI 路径: /usr/local/bin/claude
如果两个都没有,会看到错误提示并退出。
本章架构与 SDK 的对应关系
你可能会好奇:我们设计的这些层,跟之前学的 SDK API 是怎么对应的?
MiniClaw 层 对应的 SDK 概念 之前学过的章节
─────────────────────────────────────────────────────────────
CLI 交互层 receive_messages() 第 4 章
对话引擎 ClaudeSDKClient 第 4 章
工具层 create_sdk_mcp_server() / @tool 第 5 章
安全防护 hooks / can_use_tool 第 6、7 章
配置管理 ClaudeAgentOptions 第 3 章
认证检测 env / cli_path 第 3 章
前七章的知识点,在接下来的实战中会全部用到。如果某个概念记不清了,随时翻回去看。
小结
这一章我们做了四件事:
- 明确了 MiniClaw 的定位 -- 纯 Python、CLI 交互、小而完整的 AI Agent 助手
- 设计了分层架构 -- CLI 层、引擎层、工具层、存储层,各司其职
- 实现了双模式认证 -- API Key 和订阅模式自动检测,优雅降级
- 实现了配置管理 -- 默认配置 + 用户覆盖,首次运行自动创建
目前 MiniClaw 还只是个"骨架" -- 能跑起来,能检测认证,能读配置,但还不能聊天。别急,下一章我们就给它装上"大脑"。
下一章: 第 9 章 - 实战:核心引擎与记忆 -- 用 ClaudeSDKClient 实现对话引擎,用 SQLite 实现记忆系统。MiniClaw 将真正能和你聊天,而且能记住之前的对话。
本章文件清单
08-实战-项目架构设计/
README.md # 你正在读的这个文件
miniclaw/
__init__.py # 版本信息
__main__.py # 项目入口
auth.py # 双模式认证检测
config.py # 配置管理