想深入理解 Agent Harness 这套理念,光看社区文档和推文,吸收的东西还是太浅。恰好 Vibe Coding 很给力,能很快把想法落成一个能跑的东西。

实践 Agent Harness 需要一层层往上加能力,所以得先有一个自己的 Agent。于是最小原型选了文件问答 Agent:只有读和查的工具,用 CLI 一问一答,每次工具调用前停下来让我确认。

后来我给它加上写、改、删等工具,它就变成了一个简易的 Coding Agent。多轮写代码、改代码之后,上下文被撑爆了——这才引入对上下文工程的学习。


一、从文件问答 Agent 到 Coding Agent

核心就是一个 while 循环。

把系统提示词、用户问题、工具定义拼成一个消息列表,通过 API 发给大模型。模型要么调工具,要么直接回答。调了工具,就把结果追加回上下文,再发一次,直到它开口说话。

所以这个原型虽然朴素,但它是真的在调模型,不是本地模拟的。后面爆上下文,也是服务端真实返回的错误,不是我编的。

工具我一开始只给了四个:

  • list_dir(path):看看目录里有什么
  • grep(pattern, path):按关键字找到具体文件
  • read_file(path, start, end):读指定行
  • answer(text):结束循环,给出答案

技术上一丁点门槛都没有。一个下午就跑通了。跑的方式也很土:命令行里一问一答,每次工具调用前停下来等我按确认。

第一次看到它自己去 grep、自己去翻文件、自己把答案拼出来的时候,说实话挺爽的。

只读阶段一切正常。真正的麻烦,是在我给它加上写、改、删之后才来的。

加了 write_file、edit_file、delete_file,又补了一条"改完必须跑一次检查",它就有了 Coding Agent 的样子——不再是查资料,而是真的动手改东西。

上下文只增不减,而且长得比以前快:

  • 改之前要读原文件,还要读相关模块,不然容易改错地方——几千行全文直接进上下文
  • 改完要跑测试,测试输出也原样进上下文
  • 报错就重读、重改,同一个文件读第二遍、第三遍;它没有"这个文件我读过了"的记忆,两份全文就那么并排躺着
  • 每一轮的工具返回都会追加,旧版本也从不清理

改着改着就满了:请求报错,提示超出上下文长度。那一轮之后聊不下去,没有后面的对话。

这就是我去补上下文工程的契机。

上下文工程说到底就三个问题:放什么进去、按什么顺序放、什么时候清掉。 但是要理解和掌握上下文工程,离不开对 KV Cache 原理的学习。

这三个问题,后面都会落到同一句硬约束上:前面那段不能动,后面的可以压。


二、KV Cache 到底缓存了什么

先说直觉。

Transformer 每生成一个 token,都要回头看前面所有的 token。这个"看"就是注意力:每个 token 会投影出 Q(我在找什么)、K(我有什么)、V(我能给出什么),用 Q 和所有 K 做匹配,再把对应的 V 加权汇总。

最笨的做法是:每生成一个新 token,就把前面所有 token 的 K、V 全部重算一遍。

KV Cache 做的事就一句话:算过的 K、V 存下来,别算了。

新 token 来了,只算它自己的 q、k、v,把 k、v 追加进缓存,然后拿 q 跟缓存里的 K 匹配、取对应的 V。前面那几千个 token,一次都不用重算。

好比读一本书读到第 10 页,你不用把前 9 页重读一遍,脑子里留着笔记就行。KV Cache 就是模型每一层、每一个头的那份笔记。

这里有个我觉得挺有用的视角:注意力本身,做的就是一次键值检索。 当前 token 的 Q 是查询条件,上下文的 K/V 是索引,匹配上了就把对应的 V 取出来。

所以提示词不只是"一段背景描述",它更像"一份会被反复检索的索引"。这个视角能解释很多事——为什么信息堆得越多,模型反而不如堆得少;为什么同样的内容,放前面和放后面效果不一样。

落到真实推理流程上,省在哪儿就很清楚:

  • 首 token 延迟花在 prefill 上,也就是把整段提示词算一遍。这段能省,前提是这一轮的前缀和上一轮一致,缓存能命中。
  • 后续每个 token(批次小的时候)主要卡在显存带宽——每一步都得把权重和 KV Cache 从显存读一遍。

补一句容易误解的:缓存省的只是“把前缀重新投影成 K/V”和“把前缀重新读一遍”这两件重复劳动。注意力打分本身一点没省,第 t 步还是要跟前面 t 个 token 算分,这是 O(n²)。

这里有个容易混的点,值得单独拎出来:KV Cache 是模型内部的事,一次推理里把算过的 token 的 K/V 留着往下用;Prompt Cache 是服务商在多次请求之间复用相同前缀的 KV。我们能感受到的"固定前缀降低首 token 延迟",靠的就是后者命中。vLLM 的 prefix caching、SGLang 的 RadixAttention、OpenAI 和 Anthropic 的缓存能力,都属于后者。

而这些事的成立前提,就一条:

缓存只认"从头开始逐 token 相同的前缀"。前缀里哪个字符变了,从那个字符开始往后的缓存就全废,前面的部分还能用,后面的得重新 prefill。

这也解释了为什么"变量塞进系统提示词开头"最要命:改动位置越靠前,要重算的越多。


三、重写提示词:变量从前挪到后,顺手跑了一组实测

理解上面那句话之后,我判断提示词的标准彻底变了。

以前是"信息够不够全",现在是"这段内容稳不稳定"。

稳的放最前面,变的放后面:

放前面(稳定前缀)

  • 角色设定和行为规则
  • 工具定义和调用约定
  • 输出格式要求
  • few-shot 示例
  • 长期不变的长文档(比如项目规范)

放后面(易变内容)

  • 用户这一轮的问题
  • 这一轮读到的文件片段、检索结果
  • 时间戳、会话 ID、用户名
  • 任何"每轮都不一样"的东西

然后是一个特别常见、特别伤的错误写法:

# 错:把变量塞进系统提示词
system_prompt = SYSTEM_BASE + f"\n当前用户:{user_name}\n当前时间:{now}"

很多人干过这事,包括我。它的语法没问题,逻辑也没问题,但它把系统提示词从"一份稳定的契约"变成了"每次都不同的东西"。

变量不进系统提示词,只放用户提示词里。系统提示词保持逐字节不变。

顺着这条往下推,工具定义、示例、输出格式要求也都算"前缀"的一部分,同样别动——它们一改,效果和改系统提示词一样。

位置,也是设计的一部分

内容对了,放哪儿同样重要。把一次请求摊开,顺序基本是固定的:

[系统提示词] [工具定义] [示例]        ← 前缀,整段不动
[历史消息 …]                          ← 只追加,不改写
[本轮用户消息] [当前进度]              ← 每轮都变

三条原则:

  1. 越稳定的越靠前。 稳定内容放前面才吃得到前缀缓存;改动越靠前,要重算的越多。
  2. 越新的越靠后。 这一轮的问题、刚读到的片段、当前进度,天然会变,放末尾就不会污染前面的前缀。
  3. 同一句话,放系统提示词和放用户消息是两个效果。 规则、格式、边界放系统提示词——它是长期契约,模型优先遵守;这一轮的任务参数、临时提醒放最新一条用户消息,既不破坏前缀,又离模型最近。

还有个反方向的机制:模型对上下文开头和结尾的注意力明显高于中段(常说的 Lost in the Middle)。所以最关键的约束,要么放最前,要么在最新消息里再提一次,别埋在中段的历史里。

两条机制凑到一起,方向是一致的:稳定的靠前,重要的靠两头,会变的靠后。 位置这件事,不用在"省 token"和"模型看得见"之间做取舍。

直接跑一组真实 API 调用

原理说完了,直接跑一组实测。

我按自己定时任务的形态做了个实验:同一个系统提示词(约 7 万 token),跑 10 个不同任务,对比"变量写在系统提示词第一行"和"变量只进用户消息"两种写法。接口用的是 DeepSeek 的 deepseek-flash(OpenAI 兼容)。

import os, time, uuid
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")

# 随机串保证这一轮内容从没被缓存过,否则会被之前请求"预热",测不准
NONCE = uuid.uuid4().hex[:8]
RULES = "\n".join(f"规则 {i}[{NONCE}]:处理约定条款 {i}。" for i in range(1, 4001))
SYSTEM_BASE = f"你是文件问答助手[{NONCE}]。规则:\n{RULES}\n输出要求:先结论后依据。"

def messages_bad(task):
    # 变量前置:写在系统提示词第一行
    return [{"role": "system", "content": f"当前任务:{task}\n\n" + SYSTEM_BASE},
            {"role": "user", "content": "按上面的规则处理。"}]

def messages_good(task):
    # 变量后置:系统提示词逐字节不变
    return [{"role": "system", "content": SYSTEM_BASE},
            {"role": "user", "content": f"当前任务:{task}\n按上面的规则处理。"}]

def cached_of(usage):
    d = getattr(usage, "prompt_tokens_details", None)
    c = getattr(d, "cached_tokens", 0) if d else 0
    return max(c, getattr(usage, "prompt_cache_hit_tokens", 0) or 0)

def one_call(build, task):
    t0 = time.perf_counter()
    ttft, usage = None, None
    for ch in client.chat.completions.create(
        model=MODEL, messages=build(task), max_tokens=16, temperature=0,
        stream=True, stream_options={"include_usage": True},
    ):
        if ttft is None:
            ttft = time.perf_counter() - t0        # 第一个包到达
        if getattr(ch, "usage", None) is not None:
            usage = ch.usage
    return ttft, time.perf_counter() - t0, cached_of(usage)

for name, build in [("bad ", messages_bad), ("good", messages_good)]:
    batch0 = time.perf_counter()
    ttfts, tots, hits = [], [], []
    for i in range(1, 11):
        ttft, total, hit = one_call(build, f"任务 {i}")
        ttfts.append(ttft); tots.append(total); hits.append(hit)
    ttfts.sort(); tots.sort(); hits.sort()
    print(f"{name} | 中位TTFT {ttfts[5]:.2f}s | 中位总耗时 {tots[5]:.2f}s "
          f"| 整批 {time.perf_counter()-batch0:.1f}s | 中位cached {hits[5]}")

这是我真跑出来的结果(两组 prompt_tokens 都是 70,064):

bad  | 中位TTFT 0.32s | 中位总耗时 1.72s | 整批 17.4s | 中位cached 0
good | 中位TTFT 0.31s | 中位总耗时 0.97s | 整批 10.4s | 中位cached 69888

三个结论:

  1. 变量前置那组,10 次全 0,一次都没命中——第一行就变了,后面 7 万 token 跟着全部重算。
  2. 变量后置那组,命中 69,888 / 70,064,约 99.7%。
  3. 中位总耗时 1.72s → 0.97s,快约 44%;同样 10 个任务跑完,整批 17.4s → 10.4s。

这个差值会随前缀长度放大。我一开始用 2.4 万 token 的系统提示词跑,中位耗时只从 1.19s 降到 1.09s,几乎在噪声里;换成 7 万之后就一眼能看出来。前缀越长,固定前缀这个习惯越值钱。

有个反直觉的点:首包时间几乎没变(0.32s 对 0.31s)。因为这个端点的第一个包到得早,更多反映网络往返;真正被缓存救回来的 prefill,体现在总耗时上。所以测收益要看总耗时,别只看"第一个字什么时候出来"。

我在定时任务上看到的耗时下降,也是这个道理——那边前缀长、请求密集、负载可控,差值比共享端点更稳定。

几个真踩过的坑

  • 变量插在开头是最惨的。见过有人把"当前时间"写在系统提示词第一行——那不是浪费 7 个字符,是把后面几千个 token 全部推倒重来。
  • 命中是按块算的,块多大看实现。OpenAI 文档里是 1024 token 一块,自建 vLLM 的 block 小得多(几十 token 量级)。前缀不够一个块,可能根本命中不了——所以"系统提示词写得长一点、结构化一点"和"前缀要稳"这两件事是配套的。
  • 历史只追加,不改写、不重排、不插队。你以为自己在"整理"上下文,在缓存眼里是换了一整段前缀。想让模型重新看某个旧文件,别回头改历史,追加一条新消息就行。
  • 检索结果、文件片段一律放最新消息里,别插进历史中间。插一次,后面所有缓存作废一次。
  • 换模型版本、改工具定义顺序、调整示例数量,缓存都能失效。稳定性是逐字节的,不是语义的——你觉得自己只改了个措辞,缓存眼里是另一段文本。
  • 读缓存通常便宜。读缓存的价格远低于重新 prefill(Anthropic 的读缓存大致是标准输入价的十分之一),写缓存略贵一点。

这套改完,效果在我这边是能量出来的。

我有一批定时任务:每天按点跑,提示词模板一样,只有日期、任务参数这些变量在变。以前这些变量是拼在提示词开头的,等于每天、每个任务都在全量重算。改成变量后置、系统提示词逐字节固定之后,同一批任务的耗时明显下降——不是体感,是任务日志里的时间差。

这也是我第一次确认:固定前缀这件事,收益不在"理论上更优雅",而在延迟和账单上。


四、一条分类法:可重读,还是不可重读

前缀稳定解决的是"慢和贵"。但上下文该满还是会满。这时候真正救命的是一套分类法。

压成一句话:这份内容,之后能不能用一个确定的动作重新拿到?

  • 可重读:读文件、跑一次 grep、查一次检索、重跑一条命令,就能拿回来。
  • 不可重读:用户临时说的偏好、已经拍板的决定、推理出来的结论、澄清过的需求边界。这些东西丢了就是丢了,补不回来。

对应两种处理:

类型例子怎么处理
可重读文件全文、目录树、grep 结果、检索片段外置。上下文里只留"指针":路径、行号、检索语句、一句话摘要,外加"什么时候该回去读"
不可重读目标、约束、验收标准、已做的决策、用户原话落盘。写进临时目录里的结构化文件,上下文只留摘要和路径

先看第一类。读到 3000 行的文件,我不再塞全文,只留三行:

path: src/agent/loop.py(共 3120 行)
相关片段: 480-620 行,工具调用循环
备注: 需要更多时用 read_file(path, 480, 900)

模型要细节,一次工具调用就拿回来了。上下文占用从 3000 行变成 3 行,信息没真丢——只是从"内存"挪到了"磁盘"。

第二类是很多人(包括我)一开始会做错的:以为"不可重读"就等于"必须一直待在上下文里"。其实不是。

用户说"这个项目不要引入新的第三方依赖",这句话确实没法从任何地方重新推出来。但让它每轮都躺在上下文里,代价是每轮都占 token、每轮都可能被压缩掉、还可能被模型看漏。原文常驻,看似最安全,其实最容易被损耗。

更划算的做法是先写下来:

/tmp/agent-notes/constraints.md

- [2026-11-08] 不引入新的第三方依赖(用户原话)
- [2026-11-08] 只改 src/agent 下的文件
- [2026-11-08] 改完必须跑 pytest

上下文里只留这几条摘要 + 一个路径。原文不会丢——因为它已经不在上下文里了,它在磁盘上;而磁盘不会因为窗口满了就消失。

这里有个前提必须补上:模型不会主动去翻那个文件。所以摘要不能只写"见 constraints.md",得把关键结论本身写出来,再补一句"完整记录见该文件"。摘要负责让模型"知道",文件负责让模型"查得到"。

最后留一条底线:真正需要每轮都在眼前的,只有极少数几条——当轮的目标,加三五条硬约束。这几条常驻,其余全落盘。

这个分类一建立,你会发现"上下文太长"的毛病有两种:一种是把可重读的东西当不可重读的在养,另一种是把该落盘的东西硬塞在上下文里。


五、压缩:什么时候压,为什么越压越跑偏

可重读的都外置了,压力小很多,但压不到零。多轮之后还是会超。

先说清楚:压缩是最后一招。前面还有几道更便宜的手段——工具结果截断(只留命中的前后几行)、子 Agent 隔离(过程不进主上下文)、用检索替代全文、写结构化 scratchpad。这些都上完还超,才轮到压缩。

什么时候压? 我用四个触发条件:

  1. 水位:上下文占用过 70%~80% 就动手,别等报错。
  2. 边界:一个子任务做完、准备换目标时压。绝不在一次工具调用的中间压,那等于把一句话砍两半。
  3. 收益:连着几轮工具调用都没产出新信息(在原地打转),说明该收敛了。
  4. 预判:知道下一步要读一个大文件,先压出空间。

怎么压? 结构比"请总结一下上面的对话"可靠太多:

目标:<当前任务要达成什么>
已完成:<已验证的结论、已改的文件>
进行中:<现在在干什么>
待办:<还没做的>
关键决策与约束:<不可重读,保留原文>
未解决的问题:<阻塞点、存疑点>
相关文件:<路径指针,不含全文>

再加三条保护:最近若干轮保留原文,只压中间;不可重读的已经落盘、上下文只留摘要,可重读的只留指针;压完之后,把最关键的几条约束重新钉在提示词首段。还有一条和缓存直接相关——压缩只能动后面那段,别碰开头,否则省下的 token 会被重新 prefill 的代价吃回去。

那为什么压几次之后,Agent 容易跑偏?

因为压缩是有损的,而且损失很不均匀。丢掉的往往不是细节,而是"为什么做这件事"和"不能做什么"——这两类东西最不显眼,最容易被摘要当成废话删掉。

然后是三重复利式的放大:

  1. 摘要成了下一轮的输入,模型基于已经失真的摘要继续推理,误差一层层叠。
  2. 压缩改了前缀,KV Cache 全废,又慢又贵,于是你更想少放内容,质量再掉一档。
  3. 模型是自回归的,它会顺着上下文里的"叙事"往下走。摘要里写了"正在尝试方案 A",它就一路试方案 A,哪怕方案 A 早被否了。

我的解法是锚点 + 外部记忆:目标和约束做成不可压缩的锚点,每轮固定出现;过程细节写进 progress.md,压缩时只留"见 progress.md"。压缩只压指针,不压事实。压多少次,它都还记得自己为什么在这儿。


六、收尾:Agent 的上下文分三层

现在我做 Agent 的上下文,基本就按三层组织:

第一层,常量层。
系统提示词、规则、工具定义、输出格式、示例。逐字节不变,吃满前缀缓存。这一层要长、要结构化、要一次写好——它是最便宜的 token。

第二层,索引层。
进度记录、文件路径、检索语句、行号、一句话摘要——既有可重读内容的指针,也有落盘记下来的决策和约束摘要。小而结构化,随时扔掉随时拿回,原文都在磁盘上。

第三层,易变层。
本轮用户输入、本轮工具返回的片段、临时计算结果。放最后,按时间顺序排,用完即弃。

三层之外还有一层,不在上下文里:磁盘。凡是可重读的,最终都该落到那儿。

回头看这条线:我先学的是上下文工程,KV Cache 是中途补上的一课。但补完这一课,前面学的那些经验才真正落地。

因为上下文工程的那三个问题——放什么、怎么排、何时清——都有了硬依据:

  • 为什么"怎么排"这么关键:因为缓存只认前缀。
  • 为什么"放什么"要分可重读和不可重读:因为不可重读的内容压缩一次就没了。
  • 为什么"何时清"要挑时机:因为清理会改前缀,改了前缀就等于重新 prefill 一遍。

所以我现在看上下文工程,问题已经不是"该塞什么进去",而是:

什么该留在前缀里,什么该留在磁盘上,什么该在需要的时候重新读一遍。

KV Cache 看着是个推理优化细节,实际上它是"上下文怎么排"的物理定律。所以说到底:要做好上下文工程,绕不开 KV Cache 这一课。 它不是可有可无的背景知识,而是你设计上下文时必须遵守的约束条件。

Logo

更多推荐