练习 12:上下文预算

练习 3 早就说过一句话:"聊得越久、每轮越慢越贵"——历史全量重发, 输入 token 单调增长。当时这句话只是个警告,没有配一个能看的数字。 这一章把它变成一个数字:这一轮到底用掉了窗口的百分之多少,还剩多少余地。

先知道自己有多少预算,练习 13 才轮到"怎么花"。

敲进去

在练习 11 的代码上继续写。新增几个函数,外加在 main 里插几行调用。 完整文件在 exercises/ex12/

先是窗口大小和它的实验用后门:

// ---- 预算层:知道自己还有多少余地 ----

// contextWindow 返回一个模型的上下文窗口大小(token 数),蒸馏自 octo 里
// 一张更大的模型-窗口对照表——按名字子串匹配,匹配不到就退回保守的默认值。
// 宁可低估:低估最多让你提前一点行动,高估会让你真的撑爆上下文。
func contextWindow(model string) int {
	m := strings.ToLower(model)
	switch {
	case strings.Contains(m, "deepseek"):
		return 1_000_000
	case strings.Contains(m, "gpt-4"):
		return 128_000
	case strings.Contains(m, "claude"):
		return 200_000
	default:
		return 128_000 // 不认识的模型,包括本机跑的大多数开源小模型
	}
}

// effectiveContextWindow 让你在这一章的实验里用 CONTEXT_WINDOW 人为调小窗口。
// 真实模型的窗口大到几十上百万 token,正常聊天几十轮都撞不上;这一章想让你
// 在几轮之内亲眼看到预算告急,所以留了这个后门——不设就用 contextWindow 的
// 真实值,这不是在否定真实模型的窗口有多大,只是为了让实验能在你的终端里
// 几秒钟内跑完。
func effectiveContextWindow(model string) int {
	if v := os.Getenv("CONTEXT_WINDOW"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 {
			return n
		}
	}
	return contextWindow(model)
}

再是检查和估算函数:

// budgetFraction 是触发警告的门槛——占窗口的 75%,蒸馏自 octo 的
// compactThresholdFraction:剩下的 25% 留给最近的对话尾巴和这一轮的输出。
const budgetFraction = 0.75

// checkBudget 拿这一轮 API 真实回报的 token 数(不是估算值——练习 11 你
// 已经知道 API 会把这个数字如实报回来)去跟窗口比,超过门槛就在 stderr 上
// 喊一声。这一章只喊,不动手——真正"腾地方"的动作,练习 13 才做。
func checkBudget(usedTokens, window int) {
	pct := float64(usedTokens) / float64(window) * 100
	fmt.Fprintf(os.Stderr, "[预算:%d/%d tokens,%.1f%%]\n", usedTokens, window, pct)
	if float64(usedTokens) >= float64(window)*budgetFraction {
		fmt.Fprintf(os.Stderr, "⚠️  已用掉窗口的 %.0f%%,接近上限——该考虑腾地方了(练习 13)\n", pct)
	}
}

// estimateTokens 是没有真实 token 数时的快速估算:ASCII 大约 4 个字符一个
// token,中文这类多字节字符大约 1.5 个字符一个 token——不是真正的分词器,
// 只是个够用的粗略数,在还没发出第一个请求、拿不到 API 真实回报之前,
// 先给自己一个数量级。
func estimateTokens(msgs []message) int {
	total := 0
	for _, m := range msgs {
		total += estimateText(m.Content)
		for _, tc := range m.ToolCalls {
			total += estimateText(tc.Function.Name) + estimateText(tc.Function.Arguments)
		}
	}
	return total
}

func estimateText(s string) int {
	ascii, multi := 0, 0
	for _, r := range s {
		if r < 128 {
			ascii++
		} else {
			multi += utf8.RuneLen(r)
		}
	}
	return ascii/4 + int(float64(multi)/1.5+0.5)
}

接进 main。这里有个容易漏掉的时刻:-c 恢复一个老会话时,History 从磁盘读回来就已经一大坨了,但一个真实 token 数都还没有——第一个请求 根本没发出去。这一刻,checkBudget 依赖的真实数字不存在,能查的只有 估算:

	// 这一刻是估算值唯一有用武之地的时候:还没发出过任何请求,checkBudget
	// 依赖的真实数字根本不存在——尤其是 -c 恢复一个老会话时,History 可能已经
	// 很大,你想在花钱之前先摸个底,能查的只有这个粗略估算。
	window := effectiveContextWindow(model)
	preEstimate := estimateTokens(sess.History)
	fmt.Fprintf(os.Stderr, "[窗口: %s → %d tokens(发出第一个请求前,估算值: %d tokens)]\n",
		model, window, preEstimate)
	if float64(preEstimate) >= float64(window)*budgetFraction {
		fmt.Fprintf(os.Stderr, "⚠️  恢复的历史估算下来已经接近预算上限,还没发请求就先说一声——真实数字要等第一轮回来才知道\n")
	}

发出请求之后,换成真实数字:

		msg := r.Choices[0].Message
		sess.History = append(sess.History, msg)
		checkBudget(r.Usage.PromptTokens, window)

别忘了 import ("strconv"; "unicode/utf8")。先别问为什么。 敲完,跑起来,我们再回头讲。

跑起来

go build -o ex12 . && printf '购物清单\n- 牛奶\n- 面包\n' > list.txt

第一步,正常开一场新对话——真实窗口几十万 token,这个请求本身撞不上任何 门槛:

./ex12 "我叫小明,记住这个名字"

记下打印出来的会话 ID。第二步,用 -c 恢复它,同时把窗口人为调小到 估算值就已经跨过门槛的程度——注意这一次,警告要在"发出请求"之前就出现:

export CONTEXT_WINDOW=500
./ex12 -c <上一步的会话 ID> "我叫什么名字?"

你应该看到什么

第一步,新建会话——DeepSeek:

[新建会话 20260801-094211-059a90e8]
[窗口: deepseek-v4-flash → 1000000 tokens(发出第一个请求前,估算值: 465 tokens)]
[预算:927/1000000 tokens,0.1%]
好的,小明,我记住你的名字了。有什么需要帮忙的,随时告诉我。

[共 1 轮 · 最后一轮输入 927 tokens(命中缓存 896)· finish_reason=stop]
[会话 ID: 20260801-094211-059a90e8,用 -c 20260801-094211-059a90e8 继续]

第二步,恢复它,窗口调小到 500:

[恢复会话 20260801-094211-059a90e8,已有 3 条消息]
[窗口: deepseek-v4-flash → 500 tokens(发出第一个请求前,估算值: 539 tokens)]
⚠️  恢复的历史估算下来已经接近预算上限,还没发请求就先说一声——真实数字要等第一轮回来才知道
[预算:954/500 tokens,190.8%]
⚠️  已用掉窗口的 191%,接近上限——该考虑腾地方了(练习 13)
你叫小明。

[共 1 轮 · 最后一轮输入 954 tokens(命中缓存 896)· finish_reason=stop]

注意警告出现的顺序:估算值(539)先跨过门槛、先喊了一声,这时候 send() 还没被调用,一个字节都还没发给 DeepSeek;紧接着请求真的发出去, 真实值(954)回来,checkBudget 用真实数字确认了一遍,数字比估算大, 但结论(该腾地方了)是一致的。这一次估算值不是摆设——它在真实数字 诞生之前,已经先替你把话说了。

发生了什么

"聊得越久、每轮越贵",这一章给了它一把尺子。 练习 3 只让你看见输入 token 在涨;这一章告诉你涨到哪算危险——涨到窗口的 75% 就该考虑腾地方了。 "75%"不是随便定的:剩下的 25% 要留给接下来还会追加的对话、模型这一轮的 输出、以及练习 13 压缩时那个"最近的对话尾巴"要占的位置。

估算值到底有什么用——如果去掉它,会发生什么。 直接回答:去掉它, 程序几乎不受影响,除了一个时刻。那个时刻就是上面的第二步:-c 恢复一个老会话,History 从磁盘整个读回来,可能已经很大,但这时候 send() 还没被调用过一次,checkBudget 依赖的 r.Usage.PromptTokens 根本不存在——没有真实值可查。如果没有 estimateTokens,你会在这一刻 两眼一抹黑,直到发出第一个(可能很贵、可能等很久)请求、拿到结果之后, 才第一次知道这个会话原来已经这么大了。估算值不追求精确,它追求的是 "在真实值还不存在的时候,给你一个能用的数量级"——一旦真实值出现 (哪怕只是第一轮之后),后面每一次判断就都该用真实值,不再需要估算。 估算填的是"请求之前"这段真实值必然缺席的空白,不是真实值的平替。

这一章的"预算"和"上限"不是一回事。 1,000,000 是 DeepSeek 真实的 窗口——超过这个数字,请求会被服务端拒绝,是硬限制。500(或者你自己设的 任何 CONTEXT_WINDOW)是我们自己记的账,用来提前示警——超过它, 什么都不会发生,只是这一章的代码在 stderr 上多喊了一句。预算是给自己 留的缓冲带,不是协议本身的边界;缓冲带可以设得比真实边界近很多, 好处是你能早点看见麻烦,代价是你可能会因为一个偏保守的门槛提前"喊狼来了"。

真实值一旦出现,就不该再看估算。 上面的 transcript 里,估算值是 539,第一轮真实值是 954——估算漏算了 system prompt 和工具声明这些 协议开销,天生偏低。checkBudget 只吃 r.Usage.PromptTokens,从不 读 estimateTokens 的结果,就是不想让这个系统性偏低的数字去干扰 已经有真实数据的判断。估算只在真实值缺席的那一个时刻登场,退场之后 不再发言。

常见问题

  • CONTEXT_WINDOW 设置之后,实际对话会不会真的被截断:不会。 这一章只做了"记账+喊话",不做任何裁剪或压缩——历史该有多长还是多长, 该发给模型的还是照样全发。真正动手"腾地方"是练习 13 的事。
  • 我的对话超过 100% 了,为什么没报错:见"发生了什么"——预算是自己 设的记账门槛,不是协议的真实上限。只要没有真的超过模型的实际窗口 (DeepSeek 是 1,000,000),请求照样正常。把 CONTEXT_WINDOW 设得 比真实窗口还大是没有意义的;设得比真实窗口小很多,才是这一章存在 的意义——提前看见麻烦。
  • 为什么 contextWindow 里没有列出我用的模型:这只是从 octo 一张大得多的表里蒸馏出来的极简子集。真实场景里这张表需要持续维护—— 新模型发布、窗口变大,都要更新对照表,匹配不到的一律走保守兜底, 这是"够用就好"和"永远不会不安全地高估"之间的权衡。
  • estimateTokens 是不是可以直接删掉:只要你不打算支持 -c 恢复 一个可能很大的老会话,删掉确实不影响任何行为——checkBudget 从头到尾 没读过它一眼。留着它,是因为"新建会话、发第一个请求"和"恢复一个老 会话、发下一个请求"是两种不同的起点:前者 History 从零开始,天然很小, 不需要提前查;后者 History 可能已经很大,而这一刻真实值还不存在。 没有它,你只能在花掉第一个请求之后才后知后觉。

加分练习

  1. budgetFraction 从 0.75 改成 0.5 或 0.9,重跑同一个任务, 感受门槛的位置怎么影响"喊话"喊出来的时机——门槛越低,示警越早, 但也越容易在其实还有很多余地的时候就开始喊。
  2. 在发出第一个请求之前,把 estimateTokens(sess.History) 的结果和第一轮 真实回报的 r.Usage.PromptTokens 都打出来,换几个不同长度的任务试试, 记录下"真实值 / 估算值"这个比例大概落在什么范围——这就是你自己这台机器、 这套工具声明下的"协议开销倍数"。
  3. checkBudget 加一档更严重的警告——比如真实用量超过窗口 95% 时, 除了喊话,还打印一句"再来一轮基本没有余地了",模拟没有练习 13 兜底时, 一个只会喊话不会动手的系统能做到的极限。
  4. 试着不设 CONTEXT_WINDOW,让 effectiveContextWindow 用回真实的 1,000,000,再跑一遍同一个任务,确认预算行只是安静地报一个很小的百分比, 不会有任何警告——这是"够用就好"的另一面:这一章的机制平时应该是安静的, 只有真正接近边界时才该出声。