前端转 AI 第 13 天:综合项目 - 命令行 AI 助手 v1(整合前 12 天)
昨日回顾
Day 12 把 logging 和 pdb 搞定了,日志基础设施已经有了。
今天是一个里程碑——第一次把所有知识点整合成一个完整的项目。
前面 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.py | logging、RotatingFileHandler | Day 12 |
models.py | Pydantic、Literal、datetime | Day 11 |
llm.py | async/await、异常、闭包 | Day 8 |
storage.py | 文件读写、json、Path | Day 5 |
ui.py | 三方库、函数封装 | — |
commands.py | 类、字典、字符串操作 | Day 4 |
session.py | 面向对象、异步、异常 | Day 4、8 |
main.py | argparse、__main__ | — |
你会发现——每一个知识点都不是孤立的,全部串起来了。 这就是项目驱动的学习方式的魅力。
五、前端视角:这个项目像什么
| 这个项目 | 前端类比 |
|---|---|
config.py | src/config/index.ts |
logger.py | src/utils/logger.ts |
models.py | types/index.ts + zod schema |
llm.py | src/api/llm.ts(axios 封装) |
storage.py | localStorage 封装 / indexedDB |
ui.py | src/components/ 里的展示组件 |
commands.py | 路由 / 命令模式 |
session.py | store/index.ts(Redux / Pinia) |
main.py | main.tsx / index.js |
你看,这就是一个前端项目的服务端镜像。 概念全都对得上:
- Vuex / Pinia store ↔
session.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}")
可读性差距巨大。 而且 rich 的 Markdown 组件能渲染粗体、列表、代码块——模型输出直接扔进去就行。
4. 为什么控制台日志级别设为 WARNING?
因为 UI 和日志会打架。 控制台里如果有 [INFO] 日志混在对话里,整个界面就乱了。
做法:
- UI 走
print/rich——给用户看 - 日志走文件——给开发者看
需要看实时日志时:打开另一个终端 tail -f logs/assistant.log。这是开发时的常见姿势。
七、今日踩坑
asyncio.run()在循环里反复调用 → 每次都新建事件循环,效率低。更好的做法是外层asyncio.run()一次,里面用await。这里为了简单先这样,Day 14 会优化model_dump(mode="json")→ 不写mode="json",datetime 直接 dump 会报错rich的end=""和print混用 → 打字机效果偶尔会错乱,注意 flushargparse的--no-stream→ 布尔开关,加了是 True,不加是 False,容易反data/和logs/目录不存在 → 在main里调config.ensure_dirs()提前创建.env忘了创建 →load_dotenv不会报错,但配置都是默认值,容易懵session_id用uuid4().hex[:8]→ 够用,但极端情况下会撞。生产环境可以用uuid4().hex全长
八、今日总结
今天核心收获三件事:
- 项目结构决定迭代速度:分层清晰的项目,加功能是"新增模块",改功能是"改一层"
- 面向接口编程:Mock 和真实客户端同接口,Day 46 换真 API 不用改调用方
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 开发 ✅ |
| 02 | list / dict / 推导式,Python 比 JS 爽在哪 ✅ |
| 03 | 函数、参数、作用域:别再写 function 了 ✅ |
| 04 | 面向对象:class 和 JS class 的异同 ✅ |
| 05 | 文件读写与异常处理 ✅ |
| 06 | pip / conda / 虚拟环境,彻底搞懂依赖管理 ✅ |
| 07 | requests 爬虫实战:抓一个真实 API ✅ |
| 08 | 异步 asyncio:Promise 的另一种形态 ✅ |
| 09 | 正则表达式与文本处理 ✅ |
| 10 | 装饰器:Python 的"语法糖大招" ✅ |
| 11 | 类型注解与 Pydantic ✅ |
| 12 | 日志与调试:logging + pdb ✅ |
| 13 | 综合项目:命令行 AI 助手 v1 ✅ |
| 14 | 综合项目:命令行 AI 助手 v2(带记忆) |
| 15 | 阶段复盘 + 15 天踩坑合集 |
| … | … |
| 31 | FastAPI 入门:写第一个接口 |
| 46 | 调通第一个大模型 API |
| 61 | RAG 实战:让模型读懂你的文档 |
| 81 | LangChain Agent:让模型自己调工具 |
| 100 | 上线我的第一个 AI Agent 应用 |