piz 开发日志(二):架构设计与 LLM 抽象层
这是 piz 开发日志系列的第二篇。本文深入讲解 piz 的分层架构和 LLM 后端抽象层设计。
分层架构
piz 的代码组织采用了清晰的分层架构,共 5 层、23 个源文件:
┌─────────────────────────────────────────────────────────┐
│ CLI 入口层 │
│ cli.rs (clap 参数解析) → main.rs (流程调度) │
├─────────────────────────────────────────────────────────┤
│ 业务逻辑层 │
│ chat.rs │ fix.rs │ explain.rs │ update.rs │
├─────────────────────────────────────────────────────────┤
│ 核心服务层 │
│ danger.rs │ cache.rs │ executor.rs │ context.rs │
├─────────────────────────────────────────────────────────┤
│ LLM 抽象层 │
│ mod.rs (trait + factory) │ prompt.rs │
│ openai.rs │ claude.rs │ gemini.rs │ ollama.rs │
├─────────────────────────────────────────────────────────┤
│ 基础设施层 │
│ config.rs │ i18n.rs │ ui.rs │ shell_init.rs │ history │
└─────────────────────────────────────────────────────────┘
每一层有明确的职责边界:
- CLI 入口层:参数解析和流程调度,是所有功能的入口
- 业务逻辑层:各个子命令的具体实现(聊天、修复、解释、更新)
- 核心服务层:被多个业务共享的底层能力(安全检测、缓存、命令执行、环境上下文)
- LLM 抽象层:统一不同 LLM API 的差异,构建提示词
- 基础设施层:配置管理、国际化、UI 输出等基础组件
这个分层的好处是:添加新的 LLM 后端只需在 LLM 层增加文件,不影响上层;添加新的子命令只需在业务层增加文件,核心服务层可复用。
LLM 后端抽象:一个 Trait 统一四种 API
piz 支持四种 LLM 后端:OpenAI、Claude、Gemini、Ollama。它们的 API 格式完全不同——认证方式不同、请求结构不同、响应路径不同、甚至 system 消息的位置都不同。
如何用一套代码处理这些差异?答案是 Trait 抽象。
Trait 定义
#[async_trait]
pub trait LlmBackend: Send + Sync {
/// 单轮对话(翻译、解释、修复场景)
async fn chat(&self, system: &str, user: &str) -> Result<String>;
/// 多轮对话(Chat 模式场景)
async fn chat_with_history(
&self, system: &str, messages: &[Message]
) -> Result<String>;
}
设计上有几个关键决策:
Send + Sync约束:tokio 异步运行时要求 Future 可以跨线程传递- 两个方法分离:单轮对话只需 system + user 两个字符串,更简单高效;多轮对话需要完整的消息历史
- 返回
String:后端只负责获取 LLM 的原始文本响应,解析逻辑统一在调用层处理。这避免了每个后端重复写解析代码
工厂模式
pub fn create_backend(
config: &Config,
backend_override: Option<&str>,
) -> Result<Box<dyn LlmBackend>> {
let backend_name = backend_override.unwrap_or(&config.default_backend);
match backend_name {
"openai" => Ok(Box::new(OpenAiBackend::new(cfg.clone()))),
"claude" => Ok(Box::new(ClaudeBackend::new(cfg.clone()))),
"gemini" => Ok(Box::new(GeminiBackend::new(cfg.clone()))),
"ollama" => Ok(Box::new(OllamaBackend::new(cfg.clone()))),
other => anyhow::bail!("Unknown backend: {}", other),
}
}
用户可以通过配置文件设置默认后端,也可以通过 --backend 参数临时切换。工厂函数返回 Box<dyn LlmBackend>,上层代码完全不关心具体用的是哪个后端。
四种 API 的差异
这个差异表是我在实现过程中整理的,直观展示了为什么需要抽象层:
| 特性 | OpenAI | Claude | Gemini | Ollama |
|---|---|---|---|---|
| 认证方式 | Bearer {key} |
x-api-key |
x-goog-api-key |
无 |
| System 消息位置 | messages 数组首项 | 顶级 system 字段 |
system_instruction |
messages 数组首项 |
| JSON 模式 | response_format |
无(靠 Prompt) | responseMimeType |
format: "json" |
| 响应提取路径 | choices[0].message.content |
content[0].text |
candidates[0]...text |
message.content |
光是 system 消息的位置就有三种处理方式。如果不做抽象,上层代码会充斥着 if openai ... else if claude ... 的分支。
统一参数
所有后端共享相同的默认参数:
pub const DEFAULT_TEMPERATURE: f64 = 0.1; // 低温度 → 稳定输出
pub const DEFAULT_MAX_TOKENS: u32 = 2048; // 足够生成复杂命令
pub const MAX_RETRIES: u32 = 3; // 最多重试 3 次
pub const INITIAL_BACKOFF_MS: u64 = 1000; // 首次重试等 1 秒
Temperature 设为 0.1 而不是 0 是有意为之的——0 表示完全确定性,但在某些 API 上行为不一致;0.1 几乎等于确定性,同时避免了边界情况。
重试与退避
所有后端共享统一的重试逻辑:
请求失败 → 429/5xx? → 是 → attempt < 3? → 是 → 等待 1s × 2^attempt → 重试
↓ 否 ↓ 否
直接报错 报最后一次错误
指数退避时间:第 1 次 1s、第 2 次 2s、第 3 次 4s。429 是 API 限流,5xx 是服务端错误,这两种都值得重试;4xx 中的其他错误(如 401 认证失败)没有重试意义。
核心数据流
piz 的主流程是一条清晰的数据管道:
用户输入自然语言
↓
CLI 参数解析 (clap)
↓
环境上下文收集 (OS/Shell/CWD/Arch/Git/PM)
↓
缓存查询 (SHA256 key) ──命中──→ 注入检测 → 危险分级 → 确认 → 执行
↓ 未命中
构建提示词 (system + user + 环境 + 示例)
↓
调用 LLM (带重试)
↓
解析响应 (4 级回退)
↓
注入检测 → 危险分级 → 用户确认 → 执行
↓ ↓
阻断 成功 → 缓存
失败 → 自动修复
注意两个关键点:
- 缓存命中也要做注入检测——防止被 Prompt 注入污染的命令通过缓存绕过安全检查
- 先执行后缓存——只缓存成功执行的命令,避免缓存错误命令
提示词工程
piz 的提示词是整个系统中最精心设计的部分之一。共 5 种场景的提示词,每种都经过反复调优。
翻译提示词的结构
翻译是最核心的场景,提示词包含以下组成部分:
- 环境信息注入:告诉 LLM 当前的 OS、Shell、CWD、CPU 架构、是否在 Git 仓库中、有哪些包管理器
- Shell 语法提示:针对不同 Shell 给出特定语法提醒
- PowerShell:
Get-ChildItem而不是ls,$env:VAR而不是$VAR - cmd.exe:
cd /d用于跨盘符,dir而不是ls - fish:
set而不是export,; and而不是&&
- PowerShell:
- 输出格式要求:严格的 JSON 格式,包含
command、danger、explanation三个字段 - 反斜杠转义规则:明确要求 Windows 路径中的
\在 JSON 中转义为\\ - 危险等级标准:定义 safe/warning/dangerous 的判断依据
- Few-shot 示例:几个输入输出样例,帮助 LLM 理解期望格式
- 拒绝规则:非命令请求(如闲聊、数学题)返回
{"refuse": true} - 安全约束:禁止生成泄露环境变量、远程执行等恶意命令
- 上下文:如果有前一条命令的执行结果,附加在 user message 中,支持跟进请求
为什么 Shell 语法提示很重要
一个有趣的发现:如果不告诉 LLM 当前是 PowerShell,它可能会生成 ls -la —— 虽然 PowerShell 有 ls 别名,但参数不兼容。添加了 Shell 特定提示后,LLM 会正确生成 Get-ChildItem -Force 或者 ls -Force。
同样,fish shell 的语法和 bash 差异很大(没有 &&,变量赋值用 set),不提示的话 LLM 几乎总是生成 bash 语法。
多候选提示词
当用户使用 -n 3 请求多个候选时,提示词会修改输出格式要求为 JSON 数组:
[
{"command": "find . -size +100M", "danger": "safe", "explanation": "..."},
{"command": "du -h | sort -rh | head", "danger": "safe", "explanation": "..."},
{"command": "ls -lhS | head", "danger": "safe", "explanation": "..."}
]
每个候选都包含完整的 danger 和 explanation,不是简单地列出命令。
修复提示词
修复场景需要额外的信息:
你是一个命令修复助手。
失败命令: npm install
退出码: 1
错误输出: EACCES: permission denied, access '/usr/local/lib/node_modules'
常见故障模式:
- 权限不足 → sudo 或 --user
- 命令不存在 → 安装或路径修正
- 参数错误 → 修正参数
...
请返回 JSON:{diagnosis, command, danger}
把 stderr 截取前 1000 字符传给 LLM,避免太长的错误输出超出 token 限制。同时提供一个常见故障模式清单,引导 LLM 做结构化诊断。
系统上下文收集
piz 在每次调用 LLM 前都会收集当前环境信息:
pub struct SystemContext {
pub os: String, // "windows", "macos", "linux"
pub shell: String, // "powershell", "bash", "zsh", "fish", "cmd"
pub cwd: String, // 当前工作目录
pub arch: String, // "x86_64", "aarch64"
pub is_git_repo: bool, // 是否在 git 仓库中
pub package_manager: String, // "cargo", "npm", "pip", "go" 等
}
这些信息会注入到提示词中。比如在 git 仓库中问”提交代码”,LLM 能生成 git commit;检测到有 Cargo.toml 就知道是 Rust 项目;在 Windows + PowerShell 下会生成 PowerShell 语法。
包管理器检测的逻辑是扫描当前目录下的特征文件:Cargo.toml → cargo,package.json → npm,requirements.txt → pip,go.mod → go,等等。
小结
piz 的架构核心思想是分层隔离 + Trait 抽象:
- LLM 后端通过 Trait 统一,上层代码不关心具体 API 差异
- 提示词通过环境感知和 Shell 特定提示,确保生成的命令匹配用户环境
- 安全检测、缓存、执行等核心服务在独立模块中,被多个业务复用
下一篇我会深入讲解安全体系——三层纵深防御是如何设计和实现的,12 种注入检测模式各自防范什么攻击。
本文是 piz 开发日志系列的第 2 篇,共 5 篇。
项目地址:GitHub