Appearance
第 14 章:打磨 — 从 Demo 到可用产品
功能都有了,但用起来体验还差一截。怎么从"能跑"变成"好用"?
这一章要解决什么问题?
前面 13 章把所有核心功能写完了:Agent Loop、工具、持久化、上下文管理、扩展系统。但打开终端一看——没有提示、没有颜色、不知道在用哪个模型、不知道怎么退出。用户体验和一个可以交付使用的产品之间,还差"打磨"这一步。
这一章用 mini-pi-coding-agent 的实际 index.ts 来展示产品化的几个关键点:启动 Banner、ANSI 颜色、命令系统、参数解析、工具调用的可视化。
CLI 参数解析
第一个问题:用户怎么指定使用哪个模型?怎么恢复之前的会话?
typescript
const args = process.argv.slice(2);
const modelArg = args.find(a => a.startsWith("--model="))?.split("=")[1];
const resumeArg = args.find(a => a.startsWith("--resume="))?.split("=")[1];不需要引入 commander 或 yargs — 对于只有两三个参数的小工具,手动解析 process.argv 最简单。
bash
# 默认模型
npx tsx src/index.ts
# 指定模型
npx tsx src/index.ts --model=claude-sonnet-4-20250514
# 恢复会话
npx tsx src/index.ts --resume=2024-06-15_143022_a7x2启动 Banner
用户启动程序后需要立即知道三件事:这是什么、在用什么模型、怎么退出。
typescript
console.log("\x1b[36m╭─────────────────────────────────────╮\x1b[0m");
console.log("\x1b[36m│\x1b[0m \x1b[1mMini Pi Coding Agent\x1b[0m \x1b[36m│\x1b[0m");
console.log("\x1b[36m│\x1b[0m Model: \x1b[33m%-27s\x1b[0m \x1b[36m│\x1b[0m", model.id);
console.log("\x1b[36m│\x1b[0m Tools: read, write, edit, bash \x1b[36m│\x1b[0m");
console.log("\x1b[36m│\x1b[0m Type /quit to exit \x1b[36m│\x1b[0m");
console.log("\x1b[36m╰─────────────────────────────────────╯\x1b[0m");几个设计细节:
- 用 box-drawing 字符画边框(
╭╮╰╯│─),比+---+更美观 - 显示模型 ID — 用户需要确认自己在用正确的模型
- 显示可用工具列表 — 用户知道 Agent 的能力范围
- 显示退出方式 — 新用户不会被"困住"
ANSI 转义序列
终端颜色和样式通过 ANSI 转义序列实现。几个常用的:
\x1b[0m — 重置所有样式
\x1b[1m — 加粗
\x1b[31m — 红色(错误)
\x1b[32m — 绿色(成功)
\x1b[33m — 黄色(工具名称、警告)
\x1b[36m — 青色(UI 元素)
\x1b[90m — 暗灰色(次要信息)在代码中的使用模式:
typescript
// 工具调用开始 — 黄色名称 + 灰色参数
console.log(`\n\x1b[33m⚡ ${name}\x1b[0m \x1b[90m${formatToolArgs(name, args)}\x1b[0m`);
// 工具成功 — 绿色 ✓
console.log(`\x1b[32m✓ ${name}\x1b[0m \x1b[90m${preview}\x1b[0m`);
// 工具失败 — 红色 ✗
console.log(`\x1b[31m✗ ${name} failed\x1b[0m`);
// 模型思考内容 — 灰色(不抢注意力)
process.stdout.write(`\x1b[90m${text}\x1b[0m`);颜色的作用不是装饰——它建立视觉层次。用户扫一眼就能区分:模型输出(默认色)、工具操作(黄+绿/红)、系统信息(灰)、错误(红)。
斜杠命令
交互式 CLI 需要一些"元操作"——不是发给 Agent 处理的任务,而是控制 CLI 自身行为的命令。
typescript
while (true) {
const input = await question("\x1b[36m> \x1b[0m");
const trimmed = input.trim();
if (!trimmed) continue; // 空输入跳过
if (trimmed === "/quit" || trimmed === "/exit") break; // 退出
if (trimmed === "/clear") { // 清空会话
messages = [];
console.log("\x1b[90mSession cleared\x1b[0m\n");
continue;
}
if (trimmed === "/session") { // 查看会话信息
console.log(`\x1b[90mMessages: ${messages.length} | File: ${sessionFile}\x1b[0m\n`);
continue;
}
// 不是命令,发给 Agent 处理
await runAgent(trimmed, messages, config);
}设计选择:用 / 前缀区分命令和自然语言。这是 CLI Agent 的通用约定(Claude Code、Cursor 都用这种方式)。
工具调用可视化
Agent 运行时,用户需要知道正在发生什么——否则就是对着空屏幕等,不知道进度。
typescript
const config: AgentConfig = {
onToolCall: (name, args) => {
console.log(`\n\x1b[33m⚡ ${name}\x1b[0m \x1b[90m${formatToolArgs(name, args)}\x1b[0m`);
},
onToolResult: (name, result) => {
if (result.isError) {
console.log(`\x1b[31m✗ ${name} failed\x1b[0m`);
} else {
const preview = result.content.slice(0, 100).replace(/\n/g, " ");
console.log(`\x1b[32m✓ ${name}\x1b[0m \x1b[90m${preview}${result.content.length > 100 ? "..." : ""}\x1b[0m`);
}
console.log();
},
};展示效果:
> 帮我看看 package.json
⚡ read_file package.json
✓ read_file {"name":"mini-pi-coding-agent","version":"1.0.0","description":"A minimal coding agent...
这是一个 TypeScript 项目...关键设计:
- 工具名 + 关键参数,不是整个 args JSON(太长了)
- 结果只显示预览(前 100 字符),不是完整输出
- 成功/失败用不同颜色和符号(✓ / ✗)
formatToolArgs 针对每个工具提取最有用的参数:
typescript
function formatToolArgs(name: string, args: any): string {
switch (name) {
case "read_file":
case "write_file":
case "edit_file":
return args.path ?? ""; // 文件工具:只显示路径
case "bash":
return args.command?.slice(0, 60) ?? ""; // bash:只显示命令(截断)
default:
return JSON.stringify(args).slice(0, 60);
}
}错误处理的完整链路
错误可能在不同层级发生。每个层级的处理方式:
typescript
// 1. Provider 初始化失败 — 致命,直接退出
try {
providerSetup = setupProvider(modelArg);
} catch (err: any) {
console.error(`\x1b[31m${err.message}\x1b[0m`);
process.exit(1);
}
// 2. Agent 运行中的错误 — 由 Agent Loop 内部处理(不抛出)
// 模型返回 error → stopReason = "error",循环结束
// 工具执行失败 → isError = true,模型看到错误并决定下一步
// 3. 未预期的致命错误 — 顶层 catch
main().catch((err) => {
console.error("\x1b[31mFatal error:\x1b[0m", err.message);
process.exit(1);
});三层错误处理,对应三种严重程度:配置错误(无法启动)、运行时错误(Agent 自行处理)、未知错误(兜底)。
会话恢复的用户提示
恢复已有会话时,告诉用户:
typescript
if (messages.length > 0) {
console.log(`\x1b[90mResumed session with ${messages.length} messages\x1b[0m\n`);
}一句话就够了。用户知道自己用了 --resume 参数,只需要确认恢复成功。
全局结构
index.ts 的完整结构很清晰:
1. 常量(SYSTEM_PROMPT)
2. main 函数
2.1 解析 CLI 参数
2.2 初始化 provider
2.3 加载/创建会话
2.4 打印 Banner
2.5 组装 AgentConfig
2.6 readline 循环
- 斜杠命令处理
- 调用 runAgent
- 持久化新消息
2.7 退出
3. 辅助函数(formatToolArgs)
4. 顶层 catch每个部分职责单一。main() 是把所有模块组装起来的"胶水层"——provider 模块负责模型、session 模块负责存储、agent-loop 模块负责核心逻辑、tools 目录负责具体工具。index.ts 只做组装和 UI。
小结
产品化打磨不是可选的。启动 Banner 告诉用户关键信息(模型、工具、退出方式),ANSI 颜色建立视觉层次(正文/工具/系统/错误),斜杠命令提供"元操作"(退出/清空/查看状态),工具调用可视化让用户在 Agent 运行时知道进度。所有这些都不修改核心逻辑——它们通过 AgentConfig 的事件回调和外层 UI 代码实现。
下一章
Agent 写完了,能跑了,用起来也顺手了。但"能跑"不等于"正确"。怎么证明你的 Agent 确实能解决编程任务?最后一章介绍评测——用自动化测试验证 Agent 的能力。