第 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 个零件的书架。它虽然小,但五脏俱全:

本章我们先搭骨架 -- 把项目结构设计好,把认证和配置这两个"基础设施"先跑起来。


架构设计

MiniClaw 采用经典的分层架构。如果你写过 Web 应用,这套路你应该很熟悉:上层调下层,每层只管自己的事。

┌─────────────────────────────────┐
│     CLI 交互层 (cli.py)          │  <-- 用户看到的界面:输入、输出、美化
├─────────────────────────────────┤
│    对话引擎 (engine.py)          │  <-- 核心:ClaudeSDKClient 封装
├─────────────────────────────────┤
│     工具层 (tools/)              │  <-- SDK MCP 自定义工具
├─────────────────────────────────┤
│  存储层 (memory + config)        │  <-- SQLite 记忆 + JSON 配置
└─────────────────────────────────┘

打个比方:MiniClaw 就像一家小餐馆。

每层的职责:

文件 职责
CLI 交互层 cli.py 读取用户输入、格式化输出、命令解析
对话引擎 engine.py 管理 ClaudeSDKClient 生命周期、处理多轮对话
工具层 tools/ 注册和管理 MCP 工具、内置工具实现
存储层 memory.py + config.py SQLite 记忆存储、JSON 配置读写

为什么要分层?因为你可以单独替换任何一层。比如:


项目目录结构

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 需要两种都支持,而且要自动检测,不用用户手动选。

检测逻辑就像你进一家酒店:

  1. 先看你有没有带房卡(API Key) -- 有就直接进。
  2. 没房卡?看你有没有在前台登记过(claude login) -- 登记过也能进。
  3. 都没有?那对不起,请先办理入住。

用流程图表示:

开始
  │
  ▼
检查 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
}

配置管理的设计思路是默认配置 + 用户覆盖

  1. 代码里写好一套默认配置,保证即使没有配置文件也能正常运行。
  2. 如果用户创建了配置文件,就用用户的值覆盖默认值。
  3. 用户只需要写自己想改的项,不需要把所有配置都写一遍。

就像你新买了一台手机 -- 出厂设置已经能用了,你只需要改你在意的那几项(比如壁纸、铃声)。

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 章

前七章的知识点,在接下来的实战中会全部用到。如果某个概念记不清了,随时翻回去看。


小结

这一章我们做了四件事:

  1. 明确了 MiniClaw 的定位 -- 纯 Python、CLI 交互、小而完整的 AI Agent 助手
  2. 设计了分层架构 -- CLI 层、引擎层、工具层、存储层,各司其职
  3. 实现了双模式认证 -- API Key 和订阅模式自动检测,优雅降级
  4. 实现了配置管理 -- 默认配置 + 用户覆盖,首次运行自动创建

目前 MiniClaw 还只是个"骨架" -- 能跑起来,能检测认证,能读配置,但还不能聊天。别急,下一章我们就给它装上"大脑"。

下一章: 第 9 章 - 实战:核心引擎与记忆 -- 用 ClaudeSDKClient 实现对话引擎,用 SQLite 实现记忆系统。MiniClaw 将真正能和你聊天,而且能记住之前的对话。


本章文件清单

08-实战-项目架构设计/
  README.md                    # 你正在读的这个文件
  miniclaw/
    __init__.py                # 版本信息
    __main__.py                # 项目入口
    auth.py                    # 双模式认证检测
    config.py                  # 配置管理