# Step 08: 对话管理
一句话导读:给裸对话引擎装上「性格模板」(System Prompt)、「遥控器」(斜杠命令)和「油量表」(Token 估算)——从「你说什么它答什么」升级为一个有边界、可控制、可观测的对话系统。
# 一、这一步做了什么(What)
step06-07 已经能和 Claude 流式对话了,但它是「裸」的:没有行为约束、没有控制手段、看不到上下文有多大。step08 补齐三件事:
- System Prompt:一段初始化指令,通过 API 的
system参数传入,约束 AI 的角色与风格; - 斜杠命令:
/clear重置历史、/info查看对话统计,把「对话」和「控制」两条通道分开; - Token 估算:
estimateTokens()用chars/4粗算上下文大小,每轮显示tokens: X in / Y out。
代码量很小,但确立了一个 CLI AI 产品的三大基础能力:约束、控制、观测。
# 二、面试官视角:为什么要做?(Why)
面试题:System Prompt 和普通的用户消息都是文本,为什么不直接把「你是一个编程助手」拼到第一条 user 消息里,而要用单独的
system参数?
因为二者的语义地位和生命周期完全不同:
| 维度 | 塞进 user 消息 | 用 system 参数 |
|---|---|---|
| 是否进对话历史 | 是,占据一条 message | 否,独立通道,不算历史 |
| 会不会被 /clear 清掉 | 会,重置后 AI 失忆 | 不会,贯穿整个会话 |
| 权重 | 和普通用户输入同级,容易被后续对话「冲淡」 | 模型对 system 有更高的服从优先级 |
| 多轮一致性 | 需要每轮重复拼接 | 一次设定,每轮自动生效 |
一句话:System Prompt 是「宪法」,user 消息是「日常对话」。宪法不该混在日常对话里,否则清一次历史就「违宪」了。这也是为什么真实 Claude Code 把系统提示词单独放在 constants/system.ts(约 500 行),而不是散落在对话里。
# 三、原理:它是怎么工作的(How)
# System Prompt 通过独立通道注入
我们的系统提示词就是几行字符串,join('\n') 成一段:
const SYSTEM_PROMPT = [
'You are a helpful coding assistant.',
'Keep responses concise and practical.',
'When writing code, include brief explanations.',
'If unsure, ask clarifying questions.',
].join('\n');
2
3
4
5
6
关键在 streamAI 里——它作为 system 参数传给 API,和 messages 是并列的两个字段:
const stream = getClient().messages.stream({
model: process.env['MODEL'] || 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: systemPrompt || DEFAULT_SYSTEM, // ← 独立通道,不进 messages
messages, // ← 只有 history + 当前 user 输入
});
2
3
4
5
6
注意 messages 里从头到尾没有 system 提示词的影子,它走的是另一条道。
# 斜杠命令:拦截在 API 调用之前
主循环拿到输入后,先判断是不是命令,命令直接 continue,根本不发给 AI:
if (input === '/clear') {
history.length = 0; // 清空历史
turnCount = 0;
showSuccess('对话已重置');
continue; // ← 不进 streamAI,不花 token
}
if (input === '/info') {
const totalText = history.map(m => m.content).join('');
showInfo('估算 token: ' + estimateTokens(totalText) + ' (chars/4)');
continue;
}
2
3
4
5
6
7
8
9
10
11
# 数据流
用户输入
↓
是斜杠命令? ──是──> 本地处理 /clear /info → continue(不调 API)
↓ 否
turnCount++ → streamAI(input, history, writeChunk, SYSTEM_PROMPT)
↓ │ │
↓ 对话历史 宪法(独立通道)
↓
流式输出 + 显示 tokens: X in / Y out
↓
history.push(user) / history.push(assistant)
2
3
4
5
6
7
8
9
10
11
# Token 估算:粗糙但够用
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
2
3
chars/4 是英文文本的经验比值。它不精确(中文、代码符号都会偏差),但目的不是精确计费,而是让用户对「上下文在膨胀」有直觉——这是后面会话压缩、预算控制的心理铺垫。
# 四、深入追问(面试常见 follow-up)
Q:/clear 清空了 history,为什么 AI 还记得「自己是编程助手」?
A:因为 System Prompt 根本不在 history 里。/clear 只 history.length = 0,清的是对话记录;系统提示词是每轮从常量重新传入 system 参数的,和历史无关。这正是把「宪法」和「日常」分开存储的收益。
Q:斜杠命令为什么在本地拦截,而不是发给 AI 让它「理解」?
A:三个原因。一是确定性——/clear 必须百分百清空,不能靠模型「理解意图」;二是省钱省时——命令不该消耗 token 和网络往返;三是关注点分离——控制指令和自然语言对话是两种交互,混在一起模型容易误判。真实 Claude Code 的 commands/ 目录也是同样的本地拦截思路。
Q:estimateTokens 用 chars/4 这么糙,会不会误导用户?
A:会有偏差,但它的定位是「量级感知」而非「精确计费」。真实计费要用官方 tokenizer。工程上常见的做法就是先用廉价估算做 UI 提示,等真正要做预算硬控制(如 tokenBudget.ts)时再上精确方案——不为一个提示信息付出 tokenizer 的性能代价。
Q:System Prompt 越长越好吗? A:不是。它每轮都要发送,直接占用输入 token 和成本;过长还会稀释关键指令的权重。真实的 500 行是因为要塞工具定义、安全规则、输出格式等硬约束,且配合了 prompt caching 降低重复成本。我们 4 行是最小可用影子。
# 五、踩坑 / 设计权衡
- /clear 会不会误清系统提示词? 不会,但前提是你把二者分开存。如果当初图省事把系统提示词塞进
history[0],/clear就会把它一起清掉,这是新手常见 bug。 - turnCount 与 history.length 的关系:一轮对话产生两条消息(user + assistant),所以
history.length ≈ turnCount * 2。/info同时显示两者,方便区分「轮次」和「消息条数」。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
| 4 行 SYSTEM_PROMPT 常量 | constants/system.ts ~500 行 + systemPromptSections.ts 分段拼装 |
/clear /info 硬编码在主循环 | commands/ 目录,命令注册表 + 独立处理器 |
estimateTokens = chars/4 | utils/tokenBudget.ts 精确 tokenizer + 预算控制 |
| system 参数直接传字符串 | system 配合 prompt caching,降低重复发送成本 |
# 七、一句话总结
step08 = 给裸对话装三件套:System Prompt 走独立 system 通道当「不可清除的宪法」,斜杠命令在本地拦截保证控制的确定性与低成本,Token 估算让「上下文膨胀」变得可见——约束、控制、观测三件事一次补齐。
# 下一节预告
对话现在只活在内存里,关掉终端就全丢了。step09 会把对话存到磁盘,引入 /save /load /sessions 的「检查点」模式。
← 流式输出 会话持久化 保存与加载对话 →