从 Skill Loading 到 Context Compact 再到 Memory:Agent Harness 的持续运行能力构造

前面几个阶段里,Agent 已经具备了基础工具调用、Hook 扩展、Todo 规划、Subagent 拆任务等能力。

但做到这里还不够。

一个真正能长期工作的 Agent,还会遇到三个更现实的问题:

  1. 项目知识太多,不能全部塞进 system prompt;
  2. 上下文会越来越长,最终超过模型窗口;
  3. 压缩会丢失细节,新会话也无法记住用户偏好。

所以这三个章节继续增强 harness:

  • s07:Skill Loading,按需加载知识;
  • s08:Context Compact,压缩上下文;
  • s09:Memory,长期保存重要信息。

一、s07 Skill Loading:知识不要全塞进 Prompt,要按需加载

1. 问题:system prompt 不是垃圾桶

假设项目里有很多规范文档:

  • React 组件规范;
  • SQL 编写规范;
  • API 设计规范;
  • 代码审查规范。

最直接的做法是把它们全部塞进 system prompt:

1
2
3
4
5
6
SYSTEM = (
"You are a coding agent."
+ open("docs/react-style.md").read()
+ open("docs/sql-style.md").read()
+ open("docs/api-design.md").read()
)

这样看起来简单,但问题很大。

因为 system prompt 每一轮都会被带上。哪怕 Agent 只是改一个变量名,也要背着几千行规范一起请求模型。

这会导致:

  • token 浪费;
  • prompt 变重;
  • 无关知识干扰当前任务;
  • 后续上下文更容易爆掉。

所以 s07 的核心思想是:

不要把所有知识都塞进 system prompt,而是让 Agent 知道“有哪些技能”,需要时再加载完整内容。


2. 两层 Skill 设计

s07 使用两层结构:

层级 内容 放在哪里 作用
第一层 Skill 目录摘要 system prompt 告诉 Agent 有哪些 skill
第二层 完整 SKILL.md 内容 tool_result 需要时再加载

也就是说,system prompt 里只放目录:

1
2
3
4
Skills available:
- code-review: Review code quality
- sql-style: SQL writing style guide
- pdf: PDF processing guide

真正的详细内容,只有当 Agent 调用 load_skill 时才进入上下文。


3. skills/ 目录结构

每个 skill 是一个目录,里面有一个 SKILL.md

1
2
3
4
5
6
7
8
9
skills/
agent-builder/
SKILL.md
code-review/
SKILL.md
mcp-builder/
SKILL.md
pdf/
SKILL.md

SKILL.md 通常会包含:

  • name;
  • description;
  • 什么时候使用;
  • 具体操作步骤;
  • 相关资源路径;
  • 注意事项。

4. 启动时扫描 Skill

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
SKILL_REGISTRY: dict[str, dict] = {}

def _scan_skills():
if not SKILLS_DIR.exists():
return

for d in sorted(SKILLS_DIR.iterdir()):
if not d.is_dir():
continue

manifest = d / "SKILL.md"

if manifest.exists():
raw = manifest.read_text()
meta, body = _parse_frontmatter(raw)

name = meta.get("name", d.name)
desc = meta.get(
"description",
raw.split("\n")[0].lstrip("#").strip()
)

SKILL_REGISTRY[name] = {
"name": name,
"description": desc,
"content": raw
}

_scan_skills()

这段代码做了几件事:

  1. 遍历 skills/ 目录;
  2. 找每个子目录里的 SKILL.md
  3. 读取 frontmatter;
  4. 提取 name 和 description;
  5. 保存到 SKILL_REGISTRY

SKILL_REGISTRY 就是一个技能注册表。


5. 构造 system prompt 时只注入目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def list_skills() -> str:
return "\n".join(
f"- **{s['name']}**: {s['description']}"
for s in SKILL_REGISTRY.values()
)

def build_system() -> str:
catalog = list_skills()

return (
f"You are a coding agent at {WORKDIR}. "
f"Skills available:\n{catalog}\n"
"Use load_skill to get full details when needed."
)

SYSTEM = build_system()

这里的重点是:

system prompt 只放 skill 目录,不放完整 skill 内容。

这样 Agent 每轮都知道自己有哪些能力,但不会每轮都背完整文档。


6. load_skill 工具

当 Agent 判断当前任务需要某个技能时,会调用:

1
load_skill("code-review")

对应函数:

1
2
3
4
5
6
7
def load_skill(name: str) -> str:
skill = SKILL_REGISTRY.get(name)

if not skill:
return f"Skill not found: {name}"

return skill["content"]

注意这里不是直接按文件路径读取,而是通过 SKILL_REGISTRY 查找。

这样可以避免路径穿越问题,比如模型传入:

1
../../secret.txt

因为它只能加载注册表里存在的 skill。


7. 加入工具分发系统

和前面的工具一样,load_skill 也进入工具表:

1
2
3
4
5
6
7
TOOLS.append({
"name": "load_skill",
"description": "Load full skill instructions when needed",
"input_schema": ...
})

TOOL_HANDLERS["load_skill"] = load_skill

所以整个 agent_loop 不用改。

它仍然是:

1
2
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)

这就是 harness 设计得好的地方:

新能力不是重写循环,而是新增工具。


8. s07 的核心价值

s07 解决的是知识加载问题。

以前是:

1
所有知识全部塞进 system prompt

现在是:

1
2
system prompt 放目录
真正内容按需加载

它的核心价值是:

让 Agent 知道“我有什么知识”,但只在需要时付出 token 成本。


二、s08 Context Compact:上下文会爆,必须会压缩

1. 问题:messages 会无限增长

Agent 每一轮都会把内容加入 messages

  • 用户输入;
  • assistant 回复;
  • tool_use;
  • tool_result;
  • 文件内容;
  • 命令输出;
  • 错误日志。

如果 Agent 读了 30 个文件,运行了 20 条命令,这些结果都会堆在上下文里。

最后一定会遇到:

1
prompt_too_long

也就是请求超过模型上下文限制。

所以 s08 的核心思想是:

上下文一定会满,harness 必须有清理机制。


2. 总体策略:便宜的先做,贵的后做

s08 使用四层压缩:

  1. tool_result_budget:大工具结果落盘;
  2. snip_compact:裁掉中间旧消息;
  3. micro_compact:旧 tool_result 替换为占位符;
  4. compact_history:调用 LLM 总结整个历史。

前面三层不调用模型,成本低。

最后一层需要调用 LLM,总结能力强,但成本更高。

所以原则是:

cheap first, expensive last。


3. L1:snip_compact,裁掉中间对话

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def snip_compact(messages, max_messages=50):
if len(messages) <= max_messages:
return messages

keep_head, keep_tail = 3, max_messages - 3
snipped = len(messages) - keep_head - keep_tail

placeholder = {
"role": "user",
"content": f"[snipped {snipped} messages from conversation middle]"
}

return messages[:keep_head] + [placeholder] + messages[-keep_tail:]

作用是:

  • 保留开头几条消息;
  • 保留最近的消息;
  • 删除中间旧消息;
  • 用一条 placeholder 表示这里删过内容。

为什么保留头部?

因为开头可能包含初始任务、重要约束。

为什么保留尾部?

因为最近消息通常和当前任务最相关。


4. L2:micro_compact,压缩旧工具结果

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
KEEP_RECENT_TOOL_RESULTS = 3

def micro_compact(messages):
tool_results = collect_tool_result_blocks(messages)

if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
return messages

for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
if len(block.get("content", "")) > 120:
block["content"] = "[Earlier tool result compacted. Re-run if needed.]"

return messages

这个函数只保留最近 3 个工具结果的完整内容。

更旧的工具结果,如果内容比较长,就替换成:

1
[Earlier tool result compacted. Re-run if needed.]

这样做的原因是:

  • 旧文件内容通常不再重要;
  • 旧命令输出可能已经过期;
  • 如果真的需要,可以重新执行工具。

5. L3:tool_result_budget,大结果写入磁盘

有时一个工具结果本身就很大,比如一次读取 500KB 文件。

这时只靠 micro_compact 不够,因为当前最后一条 user message 里可能已经塞爆了。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def tool_result_budget(messages, max_bytes=200_000):
last = messages[-1]

blocks = [
(i, b)
for i, b in enumerate(last["content"])
if b.get("type") == "tool_result"
]

total = sum(
len(str(b.get("content", "")))
for _, b in blocks
)

if total <= max_bytes:
return messages

ranked = sorted(
blocks,
key=lambda p: len(str(p[1].get("content", ""))),
reverse=True
)

for idx, block in ranked:
if total <= max_bytes:
break

block["content"] = persist_large_output(
block["tool_use_id"],
str(block["content"])
)

total = recalculate_total(blocks)

return messages

它的逻辑是:

  1. 只检查最后一条消息里的 tool_result;
  2. 统计所有 tool_result 总大小;
  3. 如果超过 200KB,就从最大的结果开始处理;
  4. 把完整内容写入 .task_outputs/tool-results/
  5. 上下文里只保留 marker 和预览。

这样模型仍然知道:

这个结果存在,但完整内容已经被存到磁盘,需要时可以重新读。


6. 为什么执行顺序不能乱?

s08 强调执行顺序是:

1
2
3
4
tool_result_budget
snip_compact
micro_compact
compact_history

尤其是 tool_result_budget 必须在 micro_compact 前面。

原因是:

  • tool_result_budget 要保存完整大结果;
  • micro_compact 会把旧结果替换成占位符;
  • 如果先 micro,再 budget,完整内容可能已经没了,无法落盘。

所以正确顺序是:

1
2
3
4
先保存大结果
再裁消息
再压缩旧结果
最后必要时 LLM 总结

7. L4:compact_history,用 LLM 总结历史

当前三层都处理完了,但上下文还是太大,就要调用 LLM 总结。

代码:

1
2
3
4
5
6
7
8
def compact_history(messages):
transcript_path = write_transcript(messages)
summary = summarize_history(messages)

return [{
"role": "user",
"content": f"[Compacted]\n\n{summary}"
}]

它分三步:

  1. 先把完整对话写入 .transcripts/
  2. 调用 LLM 生成总结;
  3. 用一条 summary message 替换原来的 messages。

总结里要保留:

  • 当前目标;
  • 重要发现;
  • 已修改文件;
  • 剩余任务;
  • 用户约束;
  • 当前计划。

这一步会丢掉细节,但能保证 Agent 继续跑。


8. reactive_compact:出错后的紧急压缩

有时压缩触发不及时,API 直接报:

1
prompt_too_long

这时进入 reactive compact:

1
2
3
4
5
6
7
8
9
def reactive_compact(messages):
transcript = write_transcript(messages)
summary = summarize_history(messages)
tail = messages[-5:]

return [{
"role": "user",
"content": f"[Reactive compact]\n\n{summary}"
}, *tail]

它比普通 compact 更激进:

  • 保存 transcript;
  • 生成 summary;
  • 只保留最后 5 条消息;
  • 然后重试。

同时它有重试上限,防止无限循环。


9. 整合进 agent_loop

核心流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def agent_loop(messages):
reactive_retries = 0

while True:
messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)

if estimate_token_count(messages) > THRESHOLD:
messages[:] = compact_history(messages)

try:
response = client.messages.create(...)
except PromptTooLongError:
if reactive_retries < MAX_REACTIVE_RETRIES:
messages[:] = reactive_compact(messages)
reactive_retries += 1
continue

raise

# 后面继续正常工具执行

这说明 s08 的压缩逻辑发生在每次 LLM 调用之前。

也就是说:

每次请求模型前,harness 都先检查上下文是否需要清理。


10. compact 工具

s08 还增加了一个 compact 工具。

当模型主动调用它时,也会触发:

1
messages[:] = compact_history(messages)

然后返回:

1
[Compacted. History summarized.]

这表示压缩既可以由 harness 自动触发,也可以由模型主动触发。


11. s08 的核心价值

s08 解决的是上下文爆炸问题。

以前是:

1
messages 无限增长,直到 prompt_too_long

现在是:

1
每轮调用前先压缩,必要时总结,失败后紧急修复

它让 Agent 从“短会话玩具”变成“可以持续工作的系统”。


三、s09 Memory:压缩会丢细节,所以要长期记忆

1. 问题:压缩和新会话都会丢信息

s08 的 compact 可以解决上下文太长的问题。

但压缩有一个副作用:

总结是有损的。

比如用户说:

1
我以后写代码都用 tab,不用空格。

压缩后可能变成:

1
用户有代码风格偏好。

这就丢失了关键细节。

另外,新开一个会话后,之前 messages 也没了。

所以 s09 增加 Memory 层。

它解决的是:

哪些重要信息应该跨压缩、跨会话保存?


2. Memory 存在哪里?

s09 使用文件系统保存记忆:

1
2
3
4
5
.memory/
MEMORY.md
user-preference-tabs.md
project-auth-rewrite.md
feedback-no-mock-db.md

每一条 memory 是一个 Markdown 文件。

格式:

1
2
3
4
5
6
7
8
9
---
name: user-preference-tabs
description: User prefers tabs for indentation
type: user
---

User prefers using tabs, not spaces, for indentation.
**Why:** Consistency with existing codebase conventions.
**How to apply:** Always use tabs when writing or editing files.

也就是:

  • frontmatter 存元数据;
  • 正文存详细内容。

3. 四种 Memory 类型

s09 把 memory 分成四类:

类型 作用 例子
user 用户偏好 用户喜欢 tab 缩进
feedback 工作方式反馈 不要 mock 数据库
project 项目背景 当前在重写认证模块
reference 索引线索 某类 bug 在某个系统里

这四种类型分别回答不同问题:

  • user:用户是谁,偏好什么;
  • feedback:应该怎么配合用户;
  • project:当前项目发生了什么;
  • reference:以后去哪找相关信息。

4. 写入 Memory 文件

核心函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def write_memory_file(name, mem_type, description, body):
slug = name.lower().replace(" ", "-")
filepath = MEMORY_DIR / f"{slug}.md"

filepath.write_text(
f"---\n"
f"name: {name}\n"
f"description: {description}\n"
f"type: {mem_type}\n"
f"---\n\n"
f"{body}\n"
)

_rebuild_index()

它做了几件事:

  1. 把 memory 名字转成文件名;
  2. 写入 YAML frontmatter;
  3. 写入正文;
  4. 重建索引。

5. MEMORY.md:记忆索引

MEMORY.md 是所有 memory 的目录。

示例:

1
2
- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
- [project-auth-rewrite](project-auth-rewrite.md) — Auth rewrite background

它的作用类似 s07 的 skill catalog:

system prompt 里不放所有 memory 内容,只放 memory 索引。

这样模型知道有哪些记忆,但不会每次都加载所有详情。


6. build_system 注入 Memory 索引

每次构造 system prompt 时,读取 MEMORY.md

1
2
3
4
5
6
7
8
def build_system():
memory_index = read_memory_index()

return (
"You are a coding agent.\n"
f"Memory index:\n{memory_index}\n"
"Load relevant memories when useful."
)

这样 Agent 每轮都能看到记忆目录。

但完整记忆内容不会全部进入上下文。


7. 按需加载相关 Memory

s09 有一个选择相关记忆的过程。

核心函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def select_relevant_memories(messages, max_items=5):
files = list_memory_files()

if not files:
return []

catalog = "\n".join(
f"{i}: {f['name']} — {f['description']}"
for i, f in enumerate(files)
)

response = client.messages.create(
model=MODEL,
messages=[{
"role": "user",
"content": (
"Select relevant memory indices. Return JSON array.\n\n"
f"Recent conversation:\n{recent}\n\n"
f"Memory catalog:\n{catalog}"
)
}],
max_tokens=200
)

indices = json.loads(
re.search(r'\[.*?\]', response.content[0].text).group()
)

return [
files[i]["filename"]
for i in indices
if 0 <= i < len(files)
]

大概流程是:

  1. 列出所有 memory 的 name 和 description;
  2. 把最近对话和 memory catalog 发给 LLM;
  3. 让 LLM 返回相关 memory 的索引;
  4. 最多加载 5 条;
  5. 读取这些 memory 的完整内容注入上下文。

如果 LLM 选择失败,就退回关键词匹配。


8. 用户显式要求记住

第一种写入 memory 的方式很直接:

1
我以后都用 tab,记住这个。

这时 harness 可以把它写入 .memory/

这种是用户主动记忆。


9. 每轮结束后自动提取 Memory

但用户不一定每次都说“记住”。

比如他说:

1
以后字符串都用单引号。

这其实也是长期偏好。

所以 s09 在每轮自然结束后自动提取 memory:

1
2
3
4
if response.stop_reason != "tool_use":
extract_memories(messages)
consolidate_memories()
return

为什么是 stop_reason != "tool_use"

因为这说明模型这一轮已经不需要继续调用工具,当前对话阶段自然结束。

这时适合做总结提取。


10. extract_memories:从最近对话提取记忆

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def extract_memories(messages):
dialogue = format_recent_messages(messages[-10:])

existing = "\n".join(
f"- {m['name']}: {m['description']}"
for m in list_memory_files()
)

prompt = (
"Extract user preferences, constraints, or project facts.\n"
"Return JSON array: [{name, type, description, body}].\n"
"If nothing new or already covered, return [].\n\n"
f"Existing memories:\n{existing}\n\n"
f"Dialogue:\n{dialogue[:4000]}"
)

# parse response, write files

它会把最近 10 条对话发给模型,让模型判断:

  • 有没有新的用户偏好;
  • 有没有新的项目事实;
  • 有没有新的约束条件;
  • 是否已经存在类似 memory。

如果没有新内容,就返回:

1
[]

如果有,就写入 .memory/


11. consolidate_memories:记忆合并

memory 文件越来越多后,会出现:

  • 重复记忆;
  • 过期记忆;
  • 冲突记忆;
  • 描述太碎。

所以 s09 有定期整理机制:

1
2
3
4
5
6
7
8
9
10
11
CONSOLIDATE_THRESHOLD = 10

def consolidate_memories():
files = list_memory_files()

if len(files) < CONSOLIDATE_THRESHOLD:
return

# Send all memories to LLM
# Deduplicate, merge contradictions, prune stale memories
# Replace old files with consolidated results

教学版中,只要 memory 文件数量达到阈值,就触发合并。

核心作用是:

memory 不能只增不管,否则迟早变成垃圾堆。


四、三章合起来的 Harness 构造顺序

把 s07、s08、s09 串起来,完整构造过程是这样的。


第一步:保留前面已有基础

此时 harness 已经有:

1
2
3
4
5
6
agent_loop
TOOLS
TOOL_HANDLERS
HOOKS
todo_write
task / subagent

也就是说,Agent 已经能:

  • 调模型;
  • 调工具;
  • 走 hook;
  • 写 todo;
  • 拆 subagent。

第二步:扫描 skills 目录

启动时执行:

1
_scan_skills()

得到:

1
2
3
4
5
6
7
SKILL_REGISTRY = {
"code-review": {
"name": "code-review",
"description": "...",
"content": "完整 SKILL.md"
}
}

第三步:构造 system prompt 时注入 skill catalog

1
SYSTEM = build_system()

system prompt 里只放:

1
2
3
4
Skills available:
- code-review: ...
- pdf: ...
- sql-style: ...

不放完整文档。


第四步:注册 load_skill 工具

1
TOOL_HANDLERS["load_skill"] = load_skill

从此 Agent 可以按需加载技能详情。


第五步:每次 LLM 调用前执行压缩流水线

agent_loop 里,调用模型前执行:

1
2
3
messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)

如果还超:

1
messages[:] = compact_history(messages)

如果 API 报 prompt_too_long

1
messages[:] = reactive_compact(messages)

第六步:增加 compact 工具

让模型也可以主动调用:

1
TOOL_HANDLERS["compact"] = run_compact

当模型意识到上下文太重时,可以主动请求压缩。


第七步:准备 memory 文件系统

创建:

1
2
.memory/
MEMORY.md

每条长期记忆写成:

1
.memory/xxx.md

第八步:构造 system prompt 时注入 memory index

类似 skill catalog,memory 也只注入索引:

1
2
3
Memory index:
- user-preference-tabs — User prefers tabs
- project-auth-rewrite — Auth rewrite context

完整 memory 内容按需加载。


第九步:每轮调用前选择相关 memory

根据最近对话,从 memory catalog 里选出相关记忆:

1
selected = select_relevant_memories(messages)

然后读取完整内容注入当前上下文。


第十步:每轮结束后提取新 memory

当模型不再调用工具时:

1
2
3
4
if response.stop_reason != "tool_use":
extract_memories(messages)
consolidate_memories()
return

这一步让 harness 能从普通对话里自动沉淀长期信息。


五、最终运行流程

最终,这个 harness 的一轮完整流程可以理解成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
用户输入

加载 memory index

加载 skill catalog

选择相关 memory

执行上下文压缩流水线

调用 LLM

LLM 返回 tool_use?
是:

PreToolUse Hook

TOOL_HANDLERS 分发工具
- 基础工具
- todo_write
- task
- load_skill
- compact

PostToolUse Hook

tool_result 写回 messages

继续循环
否:

Stop Hook

extract_memories

consolidate_memories

退出本轮

六、总结

这三个章节继续补齐了 Agent harness 的长期运行能力。

s07 解决知识加载问题:

技能目录常驻 system prompt,完整技能按需加载。

s08 解决上下文爆炸问题:

先用低成本结构压缩,再用 LLM 总结,最后用 reactive compact 兜底。

s09 解决长期记忆问题:

把用户偏好、项目事实、反馈和参考信息保存到文件系统,跨压缩、跨会话继续使用。

如果说前面的 harness 让 Agent “能执行任务”,那么这三章让 Agent 更像一个能长期协作的开发助手:

  • Skill Loading 让它知道什么时候学;
  • Context Compact 让它不会被上下文撑爆;
  • Memory 让它不会每次都从零开始。

最终形成的不是一个简单聊天机器人,而是一个围绕 LLM 构造出来的运行系统。

一句话概括:

真正可靠的 Agent,不只是模型会回答,而是 harness 能管理知识、压缩上下文、保存记忆,并让模型在长期任务中持续保持工作能力。