前端转 AI 100 天 Day 13:综合项目——命令行 AI 助手 v1(整合前 12 天)

0 阅读16分钟

前端转 AI 第 13 天:综合项目 - 命令行 AI 助手 v1(整合前 12 天)

昨日回顾

Day 12 把 loggingpdb 搞定了,日志基础设施已经有了。

今天是一个里程碑——第一次把所有知识点整合成一个完整的项目。

前面 12 天学的东西是散的,今天要全部用上

  • 项目结构(Day 6)
  • 文件读写(Day 5)
  • 异常处理(Day 5)
  • 数据容器(Day 2)
  • 面向对象(Day 4)
  • 装饰器(Day 10)
  • 正则(Day 9)
  • 异步(Day 8)
  • Pydantic(Day 11)
  • 日志(Day 12)

先剧透一下:项目 v1 用的是"假 LLM",但除了模型调用本身,其他代码全是真实的、生产可用的。到 Day 46 换真 API,整个项目改动量不会超过 30 行。

Day 13 目标:从零做出一个能跑的命令行 AI 助手,架构清晰,代码可读。


一、项目设计

1. 功能清单

命令行 AI 助手 v1 要支持:

  • ✅ 启动一个交互式对话
  • ✅ 支持 /help/clear/quit 等斜杠命令
  • ✅ 保存对话历史到本地文件
  • ✅ 从文件加载历史对话
  • ✅ 完整的日志系统
  • ✅ 优雅的终端输出(带颜色)
  • 暂不支持真实 LLM(用 mock 模拟,Day 46 再替换)
  • 暂不支持工具调用(Day 81 加)

2. 项目结构

ai-assistant/
├── pyproject.toml
├── requirements.txt
├── .gitignore
├── .env.example
├── README.md
├── src/
│   └── ai_assistant/
│       ├── __init__.py
│       ├── main.py              # 入口
│       ├── config.py            # 配置
│       ├── models.py            # Pydantic 模型
│       ├── llm.py               # LLM 客户端(mock)
│       ├── session.py           # 对话会话
│       ├── storage.py           # 持久化
│       ├── commands.py          # 斜杠命令
│       ├── ui.py                # 终端 UI
│       └── logger.py            # 日志(Day 12 搬运)
├── data/                        # 对话历史
└── logs/                        # 日志

看这个结构,你会觉得眼熟吗? 是的,这就是后面所有 Agent 项目的基础骨架,只是功能会越加越多。

3. 架构图

┌─────────────┐
│   main.py   │  程序入口,处理命令行参数
└──────┬──────┘
       │
       ▼
┌─────────────┐
│    ui.py    │  终端交互(输入输出)
└──────┬──────┘
       │
       ▼
┌─────────────┐     ┌──────────────┐
│ session.py  │────▶│  storage.py  │  对话持久化
└──────┬──────┘     └──────────────┘
       │
       ▼
┌─────────────┐
│   llm.py    │  LLM 客户端(现在是 mock)
└─────────────┘

分层清晰,每一层只做一件事。 这就是能持续迭代的基础。


二、搭骨架

1. 创建项目

mkdir -p day13/ai-assistant && cd day13/ai-assistant
mkdir -p src/ai_assistant data logs

2. pyproject.toml

[project]
name = "ai-assistant"
version = "0.1.0"
description = "命令行 AI 助手"
requires-python = ">=3.11"
dependencies = [
    "pydantic>=2.0.0",
    "python-dotenv>=1.0.0",
    "rich>=13.0.0",
]

[project.optional-dependencies]
dev = [
    "ruff>=0.1.0",
]
pip install pydantic python-dotenv rich

rich 是什么? 一个终端格式化库,能打彩色文字、表格、Markdown、进度条。比手写 ANSI 转义码舒服一万倍。

3. .gitignore

__pycache__/
*.py[cod]
.venv/
venv/
.env
data/
logs/
.vscode/
.DS_Store

4. .env.example

ASSISTANT_NAME=AI 助手
LOG_LEVEL=INFO
DATA_DIR=data
LOG_DIR=logs

三、逐模块实现

1. config.py:配置管理

"""配置管理"""
import os
from pathlib import Path
from dotenv import load_dotenv

# 项目根目录
ROOT_DIR = Path(__file__).parent.parent.parent

# 加载 .env
load_dotenv(ROOT_DIR / ".env")


class Config:
    """应用配置"""

    APP_NAME: str = os.getenv("ASSISTANT_NAME", "AI 助手")
    LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")

    # 目录
    ROOT_DIR: Path = ROOT_DIR
    DATA_DIR: Path = ROOT_DIR / os.getenv("DATA_DIR", "data")
    LOG_DIR: Path = ROOT_DIR / os.getenv("LOG_DIR", "logs")

    @classmethod
    def ensure_dirs(cls):
        """确保所有必要目录存在"""
        cls.DATA_DIR.mkdir(exist_ok=True, parents=True)
        cls.LOG_DIR.mkdir(exist_ok=True, parents=True)


config = Config()

要点load_dotenv 会自动读 .env 里的环境变量到 os.environ敏感配置绝不写死在代码里。

2. logger.py:日志(Day 12 简化版)

"""日志工具"""
import logging
import logging.handlers
from pathlib import Path
from .config import config

_INITIALIZED = False

FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
DATEFMT = "%H:%M:%S"


def setup_logging(level: str = "INFO"):
    """初始化日志(只在入口调用一次)"""
    global _INITIALIZED
    if _INITIALIZED:
        return

    root = logging.getLogger()
    root.setLevel(getattr(logging, level.upper()))
    root.handlers.clear()

    # 控制台
    console = logging.StreamHandler()
    console.setLevel(logging.WARNING)   # 控制台只显示 WARNING 以上,避免干扰 UI
    console.setFormatter(logging.Formatter(FORMAT, datefmt=DATEFMT))
    root.addHandler(console)

    # 文件
    config.LOG_DIR.mkdir(exist_ok=True, parents=True)
    file_handler = logging.handlers.RotatingFileHandler(
        config.LOG_DIR / "assistant.log",
        maxBytes=10 * 1024 * 1024,
        backupCount=5,
        encoding="utf-8",
    )
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(logging.Formatter(FORMAT, datefmt=DATEFMT))
    root.addHandler(file_handler)

    _INITIALIZED = True


def get_logger(name: str) -> logging.Logger:
    return logging.getLogger(name)

注意:控制台级别设为 WARNING 原因是——我们要用 rich 打印漂亮的对话 UI,日志混进去会破坏界面。日志全部进文件,需要查的时候翻日志。

这是终端 UI 应用的一个常用技巧:UI 和日志走不同通道

3. models.py:数据结构

"""数据模型"""
from pydantic import BaseModel, Field
from typing import Literal
from datetime import datetime


Role = Literal["system", "user", "assistant"]


class Message(BaseModel):
    """一条对话消息"""
    role: Role
    content: str = Field(min_length=1)
    timestamp: datetime = Field(default_factory=datetime.now)

    def to_llm_format(self) -> dict:
        """转成 LLM API 需要的格式(不含 timestamp)"""
        return {"role": self.role, "content": self.content}


class Session(BaseModel):
    """一次会话"""
    session_id: str
    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: datetime = Field(default_factory=datetime.now)
    messages: list[Message] = Field(default_factory=list)
    title: str = "新对话"

    def add_message(self, role: Role, content: str) -> Message:
        msg = Message(role=role, content=content)
        self.messages.append(msg)
        self.updated_at = datetime.now()

        # 自动生成标题(取第一条用户消息)
        if self.title == "新对话" and role == "user":
            self.title = content[:20] + ("..." if len(content) > 20 else "")

        return msg

    @property
    def message_count(self) -> int:
        return len(self.messages)

    def to_llm_messages(self) -> list[dict]:
        """转成 LLM API 的 messages 数组"""
        return [m.to_llm_format() for m in self.messages]

要点Message 里带 timestamp,但 to_llm_format()去掉了——因为 LLM API 不认这个字段。

4. llm.py:LLM 客户端(Mock)

"""LLM 客户端 - Mock 版本"""
import asyncio
import random
from typing import AsyncIterator
from .logger import get_logger

logger = get_logger(__name__)


class LLMError(Exception):
    """LLM 调用失败"""
    pass


class MockLLMClient:
    """
    模拟的 LLM 客户端

    真实实现(Day 46 会替换)应该长得像:
        from openai import AsyncOpenAI
        client = AsyncOpenAI(api_key=...)
        response = await client.chat.completions.create(...)
    """

    def __init__(self, model: str = "mock-gpt", delay: float = 0.5):
        self.model = model
        self.delay = delay

    async def chat(self, messages: list[dict]) -> str:
        """
        调用模型,返回完整回复

        Args:
            messages: [{"role": "user", "content": "..."}, ...]

        Returns:
            模型回复的文本
        """
        logger.info(f"调用 LLM,共 {len(messages)} 条消息")

        # 模拟网络延迟
        await asyncio.sleep(self.delay)

        # 模拟随机失败(测试错误处理)
        if random.random() < 0.05:
            raise LLMError("模拟网络错误")

        last_user_msg = next(
            (m["content"] for m in reversed(messages) if m["role"] == "user"),
            "",
        )

        return self._generate_reply(last_user_msg)

    async def stream_chat(self, messages: list[dict]) -> AsyncIterator[str]:
        """流式返回(一个字一个字地吐)"""
        full_reply = await self.chat(messages)

        for char in full_reply:
            yield char
            await asyncio.sleep(0.02)   # 模拟打字机效果

    def _generate_reply(self, prompt: str) -> str:
        """根据 prompt 生成回复(假的,但看起来像真的)"""
        prompt = prompt.strip()

        if not prompt:
            return "你好像没有输入内容,请再试一次。"

        # 简单规则,模拟不同风格的回复
        if "你好" in prompt or "hi" in prompt.lower() or "hello" in prompt.lower():
            return "你好!我是一个模拟的 AI 助手。虽然我暂时不会真正思考,但你可以拿我测试整个对话流程。"

        if prompt.endswith("?") or prompt.endswith("?"):
            return (
                f"这是个好问题!你问的是「{prompt}」。"
                f"等 Day 46 我接入真正的大模型后,就能给你像样的答案了。"
            )

        if "python" in prompt.lower():
            return (
                "Python 是一门非常适合 AI 开发的语言。"
                "它的语法简洁、生态丰富,特别是类型注解和 Pydantic 这些特性,"
                "让数据处理和 API 开发都特别舒服。"
            )

        return (
            f"我收到了你的消息:\n「{prompt}」\n\n"
            f"(当前是 Mock 模式,回复是预设的。接入真模型后就不一样了。)"
        )

重点MockLLMClient接口和真实客户端保持一致。Day 46 我们只要写一个 OpenAIClient,实现同样的 chat()stream_chat() 方法就行。调用方的代码一行都不用改——这就是"面向接口编程"。

5. storage.py:持久化

"""对话持久化"""
import json
from pathlib import Path
from .models import Session
from .config import config
from .logger import get_logger

logger = get_logger(__name__)


class SessionStorage:
    """会话存储(JSON 文件)"""

    def __init__(self, data_dir: Path = None):
        self.data_dir = data_dir or config.DATA_DIR
        self.data_dir.mkdir(exist_ok=True, parents=True)

    def _path(self, session_id: str) -> Path:
        return self.data_dir / f"{session_id}.json"

    def save(self, session: Session) -> None:
        """保存会话"""
        path = self._path(session.session_id)
        try:
            data = session.model_dump(mode="json")
            path.write_text(
                json.dumps(data, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
            logger.debug(f"会话已保存: {path}")
        except OSError as e:
            logger.error(f"保存会话失败: {e}")
            raise

    def load(self, session_id: str) -> Session | None:
        """加载会话"""
        path = self._path(session_id)
        if not path.exists():
            logger.warning(f"会话不存在: {session_id}")
            return None

        try:
            data = json.loads(path.read_text(encoding="utf-8"))
            return Session.model_validate(data)
        except (json.JSONDecodeError, ValueError) as e:
            logger.error(f"加载会话失败: {e}")
            return None

    def list_sessions(self) -> list[dict]:
        """列出所有会话(按更新时间倒序)"""
        sessions = []
        for f in self.data_dir.glob("*.json"):
            try:
                data = json.loads(f.read_text(encoding="utf-8"))
                sessions.append({
                    "id": data["session_id"],
                    "title": data.get("title", "无标题"),
                    "updated_at": data.get("updated_at", ""),
                    "count": len(data.get("messages", [])),
                })
            except Exception as e:
                logger.warning(f"跳过损坏的会话文件 {f}: {e}")

        return sorted(sessions, key=lambda x: x["updated_at"], reverse=True)

    def delete(self, session_id: str) -> bool:
        """删除会话"""
        path = self._path(session_id)
        if path.exists():
            path.unlink()
            return True
        return False

6. ui.py:终端界面

"""终端 UI"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.prompt import Prompt

console = Console()


def print_banner(app_name: str):
    """欢迎横幅"""
    console.print()
    console.print(Panel.fit(
        f"[bold cyan]{app_name}[/bold cyan]\n"
        f"[dim]输入消息开始对话,输入 /help 查看命令[/dim]",
        border_style="cyan",
    ))
    console.print()


def print_user(text: str):
    """用户消息"""
    console.print(f"[bold green]你 ›[/bold green] {text}")


def print_assistant(text: str):
    """助手消息(支持 Markdown)"""
    console.print()
    console.print("[bold blue]AI ›[/bold blue]")
    console.print(Markdown(text))
    console.print()


def print_streaming_start():
    console.print()
    console.print("[bold blue]AI ›[/bold blue] ", end="")


def print_streaming_chunk(chunk: str):
    console.print(chunk, end="", highlight=False)


def print_streaming_end():
    console.print("\n")


def print_error(msg: str):
    console.print(f"[bold red]✗ 错误:[/bold red] {msg}")


def print_info(msg: str):
    console.print(f"[dim]ℹ {msg}[/dim]")


def print_success(msg: str):
    console.print(f"[green]✓ {msg}[/green]")


def print_session_list(sessions: list[dict]):
    """打印会话列表"""
    from rich.table import Table

    if not sessions:
        print_info("还没有任何会话")
        return

    table = Table(title="历史会话", show_header=True, header_style="bold cyan")
    table.add_column("序号", style="dim", width=6)
    table.add_column("标题", style="white")
    table.add_column("消息数", justify="right", style="cyan", width=8)
    table.add_column("更新时间", style="dim")

    for i, s in enumerate(sessions, 1):
        table.add_row(str(i), s["title"], str(s["count"]), s["updated_at"][:19])

    console.print(table)


def read_input(prompt: str = "你 › ") -> str:
    """读取用户输入"""
    try:
        return Prompt.ask(f"[bold green]{prompt}[/bold green]")
    except (EOFError, KeyboardInterrupt):
        return "/quit"

7. commands.py:斜杠命令

"""斜杠命令处理"""
from .ui import (
    print_info, print_error, print_success,
    print_session_list,
)
from .logger import get_logger

logger = get_logger(__name__)


class CommandResult:
    """命令处理结果"""
    def __init__(self, handled: bool, should_exit: bool = False):
        self.handled = handled
        self.should_exit = should_exit


class CommandHandler:
    """处理 /xxx 形式的命令"""

    def __init__(self, app):
        self.app = app   # 反向引用 app,方便操作 session
        self.commands = {
            "/help":    (self.cmd_help,    "查看帮助"),
            "/clear":   (self.cmd_clear,   "清空当前对话"),
            "/history": (self.cmd_history, "查看所有会话"),
            "/switch":  (self.cmd_switch,  "切换到指定会话(/switch <序号>)"),
            "/save":    (self.cmd_save,    "手动保存当前会话"),
            "/new":     (self.cmd_new,     "创建新会话"),
            "/quit":    (self.cmd_quit,    "退出程序"),
            "/exit":    (self.cmd_quit,    "退出程序(同 /quit)"),
        }

    def handle(self, line: str) -> CommandResult:
        if not line.startswith("/"):
            return CommandResult(handled=False)

        parts = line.strip().split(maxsplit=1)
        cmd = parts[0].lower()
        args = parts[1] if len(parts) > 1 else ""

        if cmd not in self.commands:
            print_error(f"未知命令: {cmd},输入 /help 查看所有命令")
            return CommandResult(handled=True)

        func, _ = self.commands[cmd]
        should_exit = func(args)
        return CommandResult(handled=True, should_exit=bool(should_exit))

    def cmd_help(self, args: str) -> bool:
        print_info("可用命令:")
        for name, (_, desc) in self.commands.items():
            print_info(f"  {name:10s} {desc}")
        return False

    def cmd_clear(self, args: str) -> bool:
        self.app.session.messages.clear()
        print_success("当前对话已清空")
        return False

    def cmd_history(self, args: str) -> bool:
        sessions = self.app.storage.list_sessions()
        print_session_list(sessions)
        return False

    def cmd_switch(self, args: str) -> bool:
        if not args.strip().isdigit():
            print_error("用法: /switch <序号>")
            return False

        idx = int(args.strip()) - 1
        sessions = self.app.storage.list_sessions()
        if not 0 <= idx < len(sessions):
            print_error(f"序号超出范围(1-{len(sessions)})")
            return False

        target = sessions[idx]
        session = self.app.storage.load(target["id"])
        if not session:
            print_error("加载会话失败")
            return False

        self.app.session = session
        print_success(f"已切换到:{session.title}{session.message_count} 条消息)")
        return False

    def cmd_save(self, args: str) -> bool:
        self.app.save_session()
        print_success("会话已保存")
        return False

    def cmd_new(self, args: str) -> bool:
        self.app.save_session()      # 先保存旧的
        self.app.create_new_session()
        print_success("已创建新会话")
        return False

    def cmd_quit(self, args: str) -> bool:
        print_info("再见!")
        return True

8. session.py:应用核心(整合一切)

"""应用主逻辑"""
import uuid
from .models import Session
from .llm import MockLLMClient, LLMError
from .storage import SessionStorage
from .commands import CommandHandler
from .ui import (
    print_user, print_assistant, print_error, print_info,
    print_streaming_start, print_streaming_chunk, print_streaming_end,
    read_input, print_banner,
)
from .logger import get_logger

logger = get_logger(__name__)


class AIAssistant:
    """AI 助手应用"""

    def __init__(self, use_stream: bool = True):
        self.storage = SessionStorage()
        self.llm = MockLLMClient()
        self.commands = CommandHandler(self)
        self.use_stream = use_stream

        # 加载或创建会话
        self.session: Session = None
        self.create_new_session()

    def create_new_session(self):
        """创建新会话"""
        session_id = uuid.uuid4().hex[:8]
        self.session = Session(session_id=session_id)
        logger.info(f"创建新会话: {session_id}")

    def save_session(self):
        """保存当前会话"""
        if self.session.message_count > 0:
            self.storage.save(self.session)

    def run(self):
        """主循环"""
        print_banner("AI 助手 v1")
        print_info(f"会话 ID: {self.session.session_id}")

        while True:
            try:
                line = read_input()
            except KeyboardInterrupt:
                print_info("\n收到中断信号,退出...")
                break

            if not line.strip():
                continue

            # 处理命令
            result = self.commands.handle(line)
            if result.handled:
                if result.should_exit:
                    break
                continue

            # 处理对话
            self.process_message(line)

        # 退出前保存
        self.save_session()
        logger.info("程序退出")

    def process_message(self, user_input: str):
        """处理一条用户消息"""
        # 1. 打印用户消息 + 记录
        print_user(user_input)
        self.session.add_message("user", user_input)

        # 2. 调 LLM
        try:
            if self.use_stream:
                self._handle_streaming()
            else:
                self._handle_blocking()

        except LLMError as e:
            logger.error(f"LLM 调用失败: {e}")
            print_error(f"模型调用失败: {e}")
            # 移除失败的用户消息?不,保留,让用户可以重试
            return
        except Exception as e:
            logger.exception("未预期的错误")
            print_error(f"未知错误: {e}")
            return

        # 3. 自动保存
        self.save_session()

    def _handle_blocking(self):
        """非流式处理"""
        import asyncio
        messages = self.session.to_llm_messages()
        reply = asyncio.run(self.llm.chat(messages))
        self.session.add_message("assistant", reply)
        print_assistant(reply)

    def _handle_streaming(self):
        """流式处理"""
        import asyncio
        messages = self.session.to_llm_messages()
        asyncio.run(self._stream_impl(messages))

    async def _stream_impl(self, messages):
        """流式协程"""
        print_streaming_start()
        chunks = []
        async for chunk in self.llm.stream_chat(messages):
            print_streaming_chunk(chunk)
            chunks.append(chunk)
        print_streaming_end()

        full_reply = "".join(chunks)
        self.session.add_message("assistant", full_reply)

9. main.py:入口

"""程序入口"""
import argparse
import sys

from .config import config
from .logger import setup_logging, get_logger
from .session import AIAssistant


def parse_args():
    parser = argparse.ArgumentParser(
        description="命令行 AI 助手",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "--no-stream",
        action="store_true",
        help="关闭流式输出",
    )
    parser.add_argument(
        "--log-level",
        default=config.LOG_LEVEL,
        choices=["DEBUG", "INFO", "WARNING", "ERROR"],
        help="日志级别",
    )
    return parser.parse_args()


def main():
    args = parse_args()

    # 1. 初始化目录
    config.ensure_dirs()

    # 2. 初始化日志
    setup_logging(args.log_level)
    logger = get_logger(__name__)

    # 3. 启动应用
    try:
        app = AIAssistant(use_stream=not args.no_stream)
        app.run()
    except Exception as e:
        logger.exception("应用崩溃")
        print(f"应用崩溃: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()

10. __init__.py 和运行

touch src/ai_assistant/__init__.py
python -m src.ai_assistant.main

运行效果:

╭───────────────────────────╮
│                           │
│       AI 助手 v1          │
│                           │
│  输入消息开始对话,输入    │
│  /help 查看命令           │
│                           │
╰───────────────────────────╯

ℹ 会话 ID: a3f9b2e1

你 › 你好

AI ›
你好!我是一个模拟的 AI 助手。虽然我暂时不会真正思考,
但你可以拿我测试整个对话流程。

你 › Python 是什么?

AI ›
Python 是一门非常适合 AI 开发的语言。它的语法简洁、生态丰富,
特别是类型注解和 Pydantic 这些特性,让数据处理和 API 开发都特别舒服。

你 › /help
ℹ 可用命令:
ℹ   /help      查看帮助
ℹ   /clear     清空当前对话
ℹ   /history   查看所有会话
ℹ   /switch    切换到指定会话(/switch <序号>)
ℹ   /save      手动保存当前会话
ℹ   /new       创建新会话
ℹ   /quit      退出程序
ℹ   /exit      退出程序(同 /quit)

你 › /history
      历史会话
┏━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
┃ 序号 ┃ 标题        ┃ 消息数┃ 更新时间          ┃
┡━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
│ 1    │ 你好        │     4 │ 2026-09-21 14:3… │
└──────┴─────────────┴───────┴──────────────────┘

你 › /quit
ℹ 再见!

一个真的能用的命令行助手!


四、这个项目用到了什么

复盘一下,每一个模块都对应前面某天学的知识

模块用到的知识对应 Day
config.py环境变量、Path、类属性Day 6
logger.pylogging、RotatingFileHandlerDay 12
models.pyPydantic、Literal、datetimeDay 11
llm.pyasync/await、异常、闭包Day 8
storage.py文件读写、json、PathDay 5
ui.py三方库、函数封装
commands.py类、字典、字符串操作Day 4
session.py面向对象、异步、异常Day 4、8
main.pyargparse、__main__

你会发现——每一个知识点都不是孤立的,全部串起来了。 这就是项目驱动的学习方式的魅力。


五、前端视角:这个项目像什么

这个项目前端类比
config.pysrc/config/index.ts
logger.pysrc/utils/logger.ts
models.pytypes/index.ts + zod schema
llm.pysrc/api/llm.ts(axios 封装)
storage.pylocalStorage 封装 / indexedDB
ui.pysrc/components/ 里的展示组件
commands.py路由 / 命令模式
session.pystore/index.ts(Redux / Pinia)
main.pymain.tsx / index.js

你看,这就是一个前端项目的服务端镜像。 概念全都对得上:

  • Vuex / Pinia storesession.py
  • api 层封装llm.py
  • utils 工具logger.py
  • types 定义models.py

前端的架构能力,直接迁移。


六、几个设计决策的解释

1. 为什么 Mock LLM 的接口要和真的一样?

# Mock 版本
async def chat(self, messages: list[dict]) -> str: ...

# 真实版本(Day 46 会写)
async def chat(self, messages: list[dict]) -> str: ...

接口一致,调用方无感。

这叫面向接口编程——调用方只关心"能 await chat(messages) 拿到字符串",不关心底层是 Mock 还是真 API。

好处: Day 46 加真实客户端时,session.py 一行不用改。这才是能持续迭代的架构。

2. 为什么命令处理用字典而不是 if-else?

# ❌ 天真的写法
if cmd == "/help":
    ...
elif cmd == "/clear":
    ...
elif cmd == "/history":
    ...
# 每加一个命令都要改这段

# ✅ 字典分发
self.commands = {
    "/help": (self.cmd_help, "查看帮助"),
    ...
}

加新命令只要在字典里加一行handle 方法不用动。

这是"开闭原则"的实践——对扩展开放,对修改关闭。

3. 为什么 UI 用 rich 而不是手写 ANSI?

# ❌ 手写
print(f"\033[1;32m你 ›\033[0m {text}")

# ✅ rich
console.print(f"[bold green]你 ›[/bold green] {text}")

可读性差距巨大。 而且 richMarkdown 组件能渲染粗体、列表、代码块——模型输出直接扔进去就行

4. 为什么控制台日志级别设为 WARNING?

因为 UI 和日志会打架。 控制台里如果有 [INFO] 日志混在对话里,整个界面就乱了。

做法:

  • UI 走 print / rich——给用户看
  • 日志走文件——给开发者看

需要看实时日志时:打开另一个终端 tail -f logs/assistant.log。这是开发时的常见姿势。


七、今日踩坑

  1. asyncio.run() 在循环里反复调用 → 每次都新建事件循环,效率低。更好的做法是外层 asyncio.run() 一次,里面用 await。这里为了简单先这样,Day 14 会优化
  2. model_dump(mode="json") → 不写 mode="json",datetime 直接 dump 会报错
  3. richend=""print 混用 → 打字机效果偶尔会错乱,注意 flush
  4. argparse--no-stream → 布尔开关,加了是 True,不加是 False,容易反
  5. data/logs/ 目录不存在 → 在 main 里调 config.ensure_dirs() 提前创建
  6. .env 忘了创建load_dotenv 不会报错,但配置都是默认值,容易懵
  7. session_iduuid4().hex[:8] → 够用,但极端情况下会撞。生产环境可以用 uuid4().hex 全长

八、今日总结

今天核心收获三件事:

  1. 项目结构决定迭代速度:分层清晰的项目,加功能是"新增模块",改功能是"改一层"
  2. 面向接口编程:Mock 和真实客户端同接口,Day 46 换真 API 不用改调用方
  3. rich 是终端应用的 UI 框架:表格、Markdown、颜色、进度条,直接开箱用

一个感受:

写小 demo 和写真实项目的分水岭,不是代码量,是有没有"架构意识"。
一个 500 行的脚本可能比一个 100 行的 demo 更容易维护——因为它分层清晰、职责单一、可扩展

今天这个项目,虽然功能简单(只有 Mock),但架构是生产级的
Day 14 加"对话记忆",Day 46 换真 LLM,Day 81 加工具调用,每一次都是加一层,不是改全局

从今天起,你写的不再是"练习代码",而是"项目代码"。

Day 13 完成度:✅

明日预告(Day 14)

  • 给助手加记忆功能
    • 短期记忆:消息窗口截断
    • 长期记忆:摘要压缩
    • Token 预算管理
  • 优化异步结构:asyncio.run() 移到最外层
  • 加"会话标题自动生成"
  • 加"对话导出 Markdown"
  • 小练习:让助手记住"用户偏好"

最后

如果这篇对你有用,点个赞让我知道有人在看。

今天是个小里程碑,值得发个评论说说你的感受。

有问题评论区见,我们第 14 天继续。

100 天,第 14 天见。


附:系列目录(持续更新)

标题
01从切图仔出发,100 天走向 Agent 开发 ✅
02list / dict / 推导式,Python 比 JS 爽在哪 ✅
03函数、参数、作用域:别再写 function 了 ✅
04面向对象:class 和 JS class 的异同 ✅
05文件读写与异常处理 ✅
06pip / conda / 虚拟环境,彻底搞懂依赖管理 ✅
07requests 爬虫实战:抓一个真实 API ✅
08异步 asyncio:Promise 的另一种形态 ✅
09正则表达式与文本处理 ✅
10装饰器:Python 的"语法糖大招" ✅
11类型注解与 Pydantic ✅
12日志与调试:logging + pdb ✅
13综合项目:命令行 AI 助手 v1 ✅
14综合项目:命令行 AI 助手 v2(带记忆)
15阶段复盘 + 15 天踩坑合集
31FastAPI 入门:写第一个接口
46调通第一个大模型 API
61RAG 实战:让模型读懂你的文档
81LangChain Agent:让模型自己调工具
100上线我的第一个 AI Agent 应用