# Step 08: 对话管理

一句话导读:给裸对话引擎装上「性格模板」(System Prompt)、「遥控器」(斜杠命令)和「油量表」(Token 估算)——从「你说什么它答什么」升级为一个有边界、可控制、可观测的对话系统。


# 一、这一步做了什么(What)

step06-07 已经能和 Claude 流式对话了,但它是「裸」的:没有行为约束、没有控制手段、看不到上下文有多大。step08 补齐三件事:

  1. System Prompt:一段初始化指令,通过 API 的 system 参数传入,约束 AI 的角色与风格;
  2. 斜杠命令:/clear 重置历史、/info 查看对话统计,把「对话」和「控制」两条通道分开;
  3. 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');
1
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 输入
});
1
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;
}
1
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)
1
2
3
4
5
6
7
8
9
10
11

# Token 估算:粗糙但够用

export function estimateTokens(text: string): number {
  return Math.ceil(text.length / 4);
}
1
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 的「检查点」模式。