Harness
Harness
Harness 的本质,就是为大模型写一个微型操作系统(OS)。在这个 OS 里,大模型是 CPU,上下文窗口是极其珍贵的 RAM(内存),各种本地操作是外设(硬件)。Harness 不干涉 CPU 的计算,但它必须极其严苛地管理内存回收(上下文压缩)、调度硬件接口(极简工具集)、并随时准备触发系统中断(拦截高危操作与死循环)。
如果大模型的上下文窗口(Context Window)逼近了极限限制(比如 128k Tokens),一个agent项目应该采取哪些类似 OS 的策略,来避免整个 Agent 因为 API 报错而彻底崩溃失忆?
核心原则只有一句:
把 Context Window 当作易失性 RAM,把模型外的持久化状态当作真正内存。LLM Worker 应该随时可销毁、可重建。
单纯“快满了就总结一下”不够。可靠的 Agent Runtime 应同时具备以下 OS 式机制。
机制映射
| OS 机制 | Agent 中的实现 |
|---|---|
| 内存配额 | 每次 API 调用前精确计算 Token,输入、输出预留、System Prompt、Tool Schema 全部计入 |
| 工作集 | Prompt 只装载当前目标、约束、当前步骤、近期消息和直接相关证据 |
| 虚拟内存 / 分页 | 完整对话、文档、日志、工具输出放在数据库或对象存储,需要时检索加载 |
| Pinned Pages | 系统规则、用户硬约束、验收条件、未提交副作用永不被普通摘要淘汰 |
| 页面置换 | 优先移除重复消息、旧日志、已完成步骤、可重新获取的数据,而非简单删除最老消息 |
| GC / Compaction | 将旧上下文压缩成结构化阶段摘要,并保留原始事件引用 |
| PCB 进程控制块 | 保存任务目标、计划、决定、待办、制品引用、执行游标等最小恢复状态 |
| Checkpoint + WAL | 所有输入、决定、工具调用和结果先写事件日志,再原子更新检查点 |
| Supervisor | 捕获超限、超时、429、5xx 和 Worker 崩溃,从检查点启动新 Worker |
| Admission Control | 大型工具输出不得直接进入 Prompt,必须分页、截取或外置 |
| 进程隔离 | 子 Agent 使用独立上下文,只向父 Agent返回结构化结论和 artifact 引用 |
| Outbox / 幂等 I/O | 发邮件、付款、写数据库等动作携带幂等键,恢复后不会重复执行 |
分层记忆
建议至少分成五层:
- L0 Kernel:系统策略、安全规则、用户硬约束。
- L1 Task PCB:目标、验收条件、当前步骤、关键决定、开放事项、未完成副作用。
- L2 Working Set:最近几轮消息、当前工具调用和精确证据。
- L3 Memory Index:历史摘要、事实、决定、实体索引,可混合关键词和向量检索。
- L4 Archive:完整原始消息、工具输出、文件、网页快照和事件日志。
摘要只是缓存,L4 才是事实源。不要进行无限的“摘要的摘要”,否则约束、否定词、数字和来源会逐代漂移。
Token 水位线
安全输入上限应这样计算:
safe_input =
model_context_limit
- reserved_output_tokens
- tokenizer_error_p99
- safety_margin以 128k 为例,可采用类似配置:
- 约 60%:正常运行。
- 约 70%:开始生成新 checkpoint,限制冗长工具输出。
- 约 80%:主动 compaction,并准备新 context generation。
- 约 85%:强制从检查点重建全新上下文。
- 超过安全输入上限:绝不发送 API 请求。
例如预留 16k 输出、4k Token 估算误差和 8k 安全空间,实际输入硬上限大约是 100k,而不是 128k。
还应监控:
预计剩余轮数 = 剩余安全 Token / 每轮 Token 增长 EMA只剩 3 到 5 轮时就应提前换代,避免压缩风暴。
检查点内容
不要只保存一段自然语言摘要。至少保存:
{
"goal": "...",
"acceptance_criteria": [],
"constraints": [],
"current_step": "...",
"completed_steps": [],
"decisions": [
{"claim": "...", "source_event_ids": []}
],
"open_loops": [],
"artifact_refs": [],
"pending_effects": [
{"idempotency_key": "...", "status": "planned"}
],
"event_cursor": 1842,
"resume_instruction": "..."
}检查点应版本化、原子写入,并保留 last-known-good 版本。
超限恢复流程
1. 将用户输入先写入 WAL
2. Context Builder 装配工作集
3. 调用前执行 Token 准入检查
4. 超预算则外置大内容、压缩已完成阶段、生成新 checkpoint
5. 从 checkpoint + 最近事件 + 相关证据构造全新 Prompt
6. 再次执行准入检查
7. 调用模型
8. 验证输出后才提交状态和执行工具如果 API 仍返回 context_length_exceeded:
- 不要原样重试。
- 将错误统一映射为内部
CONTEXT_EXHAUSTED。 - 使用最小恢复上下文重新装箱。
- 降低输出预算。
- 最多进行一到两次有界重试。
- 仍失败则拆分子任务或返回可恢复状态,不能清空任务。
最容易踩的坑
- 把聊天历史当数据库。
- 直到 API 报错才开始压缩。
- 直接删除最老消息。
- 把全部历史压成无来源的散文摘要。
- 只依赖向量数据库保存关键约束。
- 将完整日志、CSV、网页或 Base64 塞入 Prompt。
- 超限后原样重试,形成永久崩溃循环。
- 工具执行成功但尚未记录,重启后重复付款或发信。
- 把所有子 Agent 的完整 Transcript 合并回主 Agent。
- 认为 Prompt Cache 可以增加窗口容量,它只能降低成本或延迟。
最终验收标准应该是:
在模型调用前后、工具执行前后、检查点写入期间任意杀死进程,系统仍能恢复目标、约束、开放事项和已提交动作,并且最多重做最后一个幂等步骤。
go优化问题
仔细观察目前的 loop.go 代码,当大模型在一个 Turn 里返回了多个 ToolCall 时,我们是通过一个 for 循环串行(Sequential)地去调用 e.registry.Execute 的:
for _, toolCall := range responseMsg.ToolCalls {
result := e.registry.Execute(ctx, toolCall)
// ...
}
假设大模型非常聪明,它为了加快速度,同时请求了读取 3 个完全独立的文件。以我们目前的串行写法,必须等第一个文件读完并返回,才会去读第二个文件。
作为一名专业的 Go 开发工程师,你能想到如何利用 Go 语言的原生特性(比如 Goroutine 和 WaitGroup),将这里的工具执行改造为并行执行(Parallel Execution)吗?如果在并行执行中某个工具报错了,又该如何将所有并行的结果(Observation)按照正确的顺序组装回 Context 中?
核心是“两阶段执行”:
- Goroutine 并行执行,每个结果写入预分配切片的固定下标。
Wait()后由主 Goroutine 按原顺序组装 Observation。
type toolOutcome struct {
result ToolResult // 替换为项目中的实际类型
err error
}
func (e *Engine) executeToolCalls(
ctx context.Context,
calls []ToolCall,
) []toolOutcome {
outcomes := make([]toolOutcome, len(calls))
var wg sync.WaitGroup
wg.Add(len(calls))
for i, call := range calls {
// 兼容 Go 1.22 之前的循环变量捕获语义。
i, call := i, call
go func() {
defer wg.Done()
result, err := e.registry.Execute(ctx, call)
outcomes[i] = toolOutcome{
result: result,
err: err,
}
}()
}
wg.Wait()
return outcomes
}然后按原始 ToolCalls 顺序写回 Context:
calls := responseMsg.ToolCalls
outcomes := e.executeToolCalls(ctx, calls)
for i, call := range calls {
outcome := outcomes[i]
observation := buildObservation(
call.ID,
outcome.result,
outcome.err,
)
conversation.Messages = append(
conversation.Messages,
observation,
)
}buildObservation 应保证成功和失败都生成一条与原 ToolCallID 对应的 tool message。例如工具失败时,将错误编码为模型可理解的 Observation,而不是立即中断整个批次:
{
"ok": false,
"error": "open config.json: file not found"
}这样即使完成顺序是 call-3 -> call-1 -> call-2,最终 Context 仍然是 call-1 -> call-2 -> call-3。
关键注意事项:
- 不要在 Goroutine 中并发
append同一个 Context/messages slice。 - 不同 Goroutine 写预分配切片的不同下标是安全的;
Wait()后再读取。 - 单个工具错误不应取消其他独立工具,否则会缺失部分
tool_call_id的 Observation。 errgroup.WithContext默认首错取消,不适合“收集全部结果”的语义。registry及具体 Tool handler 必须支持并发调用。- 有写操作或调用间依赖的工具不能盲目并行,最好通过
ParallelSafe/ReadOnly元数据控制。 - 生产环境建议增加最大并发数,避免模型一次返回大量 ToolCall。
- 测试应故意让工具逆序完成,并运行
go test -race ./...验证顺序与数据竞争。
当前任务目录没有挂载 Go 源码,因此无法直接修改真实类型和调用点;以上代码需要将 ToolResult、ToolCall、buildObservation 替换为项目中的实际类型。