从 System Prompt 到 Error Recovery 再到 Task System:Agent Harness 的工程化构造
从 System Prompt 到 Error Recovery 再到 Task System:Agent Harness 的工程化构造
前面几个阶段里,Agent 已经具备了工具调用、Hook 扩展、Todo 规划、Subagent 拆任务、Skill 按需加载、Context 压缩和 Memory 长期记忆。
做到这里,Agent 已经不再是一个简单聊天程序,而是逐渐变成了一个可以处理复杂开发任务的系统。
但是如果继续往工程化方向走,还会遇到三个问题:
- system prompt 越来越长,不能再硬编码;
- API 调用可能失败,Agent 不能一报错就崩;
- Todo 只能管当前会话,不能管理跨会话、带依赖的大任务。
所以这三个章节继续补齐 harness 的三层能力:
- s10:System Prompt,运行时组装 prompt;
- s11:Error Recovery,错误恢复和重试;
- s12:Task System,持久化任务系统。
一、s10 System Prompt:Prompt 应该运行时组装,而不是硬编码
1. 问题:硬编码 SYSTEM 会越来越难维护
最开始的 Agent 只有几个工具,所以 system prompt 可以很简单:
1 | SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks." |
但是随着功能越来越多,prompt 也越来越长。
后面要告诉模型:
- 你是 coding agent;
- 你应该使用工具解决问题;
- 多步骤任务前要先 todo_write;
- 可以使用 load_skill 加载技能;
- 有 memory 时要参考 memory;
- 当前 workspace 是哪里;
- 哪些工具可用;
- 上下文可能被 compact;
- 遇到任务可以拆给 subagent。
如果全部写成一个大字符串,就会变成:
1 | SYSTEM = ( |
这样有三个问题:
第一,换项目时很难改。
第二,加一个功能可能影响别的说明。
第三,每次请求都带上所有内容,哪怕当前任务根本不需要。
所以 s10 的核心思想是:
system prompt 不应该写死,而应该根据当前运行状态动态组装。
2. PROMPT_SECTIONS:把 Prompt 拆成模块
s10 先把原来的大 prompt 拆成多个片段:
1 | PROMPT_SECTIONS = { |
每个 section 负责一个主题:
identity:Agent 身份和工作方式;tools:当前可用工具;workspace:当前工作目录;memory:记忆相关说明。
这样做的好处是:
每个 prompt 片段可以单独维护,不会互相污染。
比如你要改工具说明,只需要改 tools。
你要加 memory,只需要新增 memory 相关部分。
不用在一个巨大的字符串里到处找位置。
3. assemble_system_prompt:按需组装
有了 section 后,就需要一个组装函数:
1 | def assemble_system_prompt(context: dict) -> str: |
这里分成两类:
第一类是永远加载:
1 | identity |
第二类是按需加载:
1 | memory |
如果没有 memory,就不把 memory section 放进去。
这说明 prompt 的组装不是靠猜关键词,而是靠运行时真实状态。
4. context:用真实状态决定 Prompt 内容
s10 用 context 记录当前 harness 的真实状态:
1 | def update_context(context: dict, messages: list) -> dict: |
这里面有三个关键字段:
1 | enabled_tools |
它们都不是模型猜出来的,而是 harness 实际检查出来的。
比如:
1 | "enabled_tools": list(TOOL_HANDLERS.keys()) |
表示当前真正注册了哪些工具。
1 | "memories": memories |
表示 .memory/MEMORY.md 里是否真的有内容。
所以 s10 的 prompt 装配逻辑是:
根据系统真实状态决定加载哪些 prompt section。
这比“看用户有没有提到 memory”更可靠。
5. get_system_prompt:加一层缓存
如果 context 没变,每轮都重新拼 prompt 就没必要。
所以 s10 加了缓存:
1 | def get_system_prompt(context: dict) -> str: |
这里有一个小细节:为什么不用 hash(context)?
因为 Python 的 dict/list 不能直接 hash。
而且 Python 内置 hash() 有随机化,不适合做稳定缓存 key。
所以这里用:
1 | json.dumps(context, sort_keys=True) |
把 context 转成稳定字符串。
如果字符串没变,说明 context 没变,就直接返回上一次的 prompt。
6. 整合进 agent_loop
最终循环里这样用:
1 | def agent_loop(messages: list, context: dict): |
每一轮都会:
- 根据 context 获取 system prompt;
- 调用模型;
- 执行工具;
- 更新 context;
- 必要时重新组装 prompt。
7. s10 的核心价值
s10 解决的是 prompt 工程化问题。
以前是:
1 | SYSTEM 是一个写死的大字符串 |
现在是:
1 | PROMPT_SECTIONS 分段管理 |
它让 prompt 从“写死的文本”变成了“运行时配置”。
一句话概括:
Prompt 不应该越写越长,而应该像系统配置一样按模块组装。
二、s11 Error Recovery:错误不是结束,而是重试的开始
1. 问题:Agent 不能一报错就崩
真实环境里,LLM API 调用失败很正常。
常见错误有:
- 输出太长,被
max_tokens截断; - 上下文太长,出现
prompt_too_long; - 请求太频繁,出现 429;
- 服务过载,出现 529;
- 网络波动;
- 超时。
如果没有错误恢复,Agent 只要遇到一次异常就会退出。
这在玩具 demo 里还能接受,但在真实 Agent 里不行。
所以 s11 的核心思想是:
错误不是终点,而是进入恢复路径的起点。
2. 三类主要恢复路径
s11 主要处理三种常见问题:
| 错误类型 | 触发条件 | 恢复方式 |
|---|---|---|
| 输出被截断 | stop_reason == "max_tokens" |
提升 max_tokens 或继续生成 |
| 上下文太长 | prompt_too_long |
reactive compact 后重试 |
| 临时失败 | 429 / 529 | 指数退避重试,必要时切换模型 |
这三类错误分别放在不同位置处理。
3. RecoveryState:记录恢复状态
错误恢复需要状态。
比如:
- 是否已经提升过 max_tokens;
- 是否已经 reactive compact;
- 当前连续 529 几次;
- 当前使用哪个模型;
- recovery 已经重试几次。
所以 s11 会有类似 RecoveryState 的结构。
伪代码可以理解成:
1 | @dataclass |
它不是业务逻辑,而是 harness 的运行状态。
4. 路径一:输出被 max_tokens 截断
模型生成太长时,会出现:
1 | response.stop_reason == "max_tokens" |
这说明模型还没说完,但输出 token 用完了。
s11 的处理分两步。
第一步,先提高输出上限:
1 | if response.stop_reason == "max_tokens": |
默认可能是:
1 | max_tokens = 8000 |
第一次截断后提升到:
1 | ESCALATED_MAX_TOKENS = 64000 |
注意这里有一个非常重要的点:
第一次截断时,不把被截断的 assistant 内容加入 messages。
因为它要用更大的 max_tokens 重试同一个请求。
如果把截断内容加入 messages,就会污染上下文。
5. 如果 64K 还不够:续写
如果已经提升过 max_tokens,但还是截断,就保存当前输出,然后加一条续写提示:
1 | messages.append({ |
然后继续循环。
这条 prompt 的意思是:
不要道歉,不要总结,直接从断掉的地方继续。
续写不是无限的,一般最多几次。
否则模型可能一直写不完,浪费 token。
6. 路径二:prompt_too_long 上下文溢出
如果请求模型时直接报:
1 | prompt_too_long |
说明上下文太长了。
s08 已经有自动 compact,但可能还是不够。
所以 s11 兜底处理:
1 | except PromptTooLongError: |
第一次遇到时,执行:
1 | reactive_compact(messages) |
然后重试。
如果已经 reactive compact 过一次,还是太长,就退出。
因为继续压缩通常也没意义了。
7. 路径三:429 / 529 临时失败
429 和 529 通常不是代码错,而是服务暂时不可用。
所以处理方式不是退出,而是等待后重试。
核心函数:
1 | def retry_delay(attempt, retry_after=None): |
这叫指数退避。
大概是:
1 | 第 1 次:0.5 秒 |
再加一点随机 jitter,避免大量请求同时重试。
8. with_retry:包装 API 调用
1 | def with_retry(fn, state, max_retries=10): |
这里有两个点。
第一,最多重试 10 次。
第二,如果连续 529,说明当前模型过载严重,可以切换 fallback model。
比如从一个更强但拥挤的模型切到一个更稳定的模型。
9. 整合进 agent_loop
s11 的 agent_loop 大概是这样:
1 | def agent_loop(messages, context): |
注意处理顺序:
- API 异常用 try/except;
- 429/529 用
with_retry; max_tokens是 response 返回后的 stop_reason;- 正常情况才 append assistant message;
- 出错恢复后用
continue回到循环顶部重试。
10. s11 的核心价值
s11 解决的是 Agent 稳定性问题。
以前是:
1 | API 报错 → Agent 崩溃 |
现在是:
1 | 输出截断 → 提升 max_tokens / 续写 |
它让 Agent 从“能跑”变成“出错后还能继续跑”。
一句话概括:
工程化 Agent 必须默认错误会发生,并把错误当成流程的一部分处理。
三、s12 Task System:Todo 不是任务系统
1. 问题:TodoWrite 只能管理当前执行步骤
s05 里已经有了 todo_write。
它适合做当前任务的执行清单,比如:
1 | 1. 读取文件 |
但是它有明显限制:
- 只存在当前进程内;
- 会话结束就没了;
- 没有任务依赖;
- 没有 owner;
- 不能跨 session 恢复;
- 不能支持多 Agent 协作。
如果任务变成:
1 | 搭数据库 |
这就不是普通 todo 了。
因为它们有依赖关系:
1 | 写 API 依赖数据库 |
所以 s12 引入 Task System。
核心思想是:
大目标要拆成持久化任务,任务之间可以有依赖关系。
2. TodoWrite 和 Task System 的区别
| 对比项 | TodoWrite | Task System |
|---|---|---|
| 用途 | 当前任务执行清单 | 长期任务管理 |
| 存储 | 内存 | .tasks/{id}.json |
| 是否跨会话 | 否 | 是 |
| 依赖关系 | 没有 | blockedBy |
| owner | 没有 | 有 |
| 适合场景 | 当前步骤跟踪 | 项目级任务拆解 |
| 粒度 | Agent 自己的操作步骤 | 可领取、可阻塞、可恢复的任务 |
所以 Task System 不是 TodoWrite 的简单升级。
它们是两个层级:
- TodoWrite 管“我现在怎么干”;
- Task System 管“整个项目有哪些任务,先后顺序是什么”。
3. Task 数据结构
s12 使用 dataclass 表示任务:
1 | @dataclass |
每个字段含义:
1 | id |
任务唯一 ID。
1 | subject |
任务标题。
1 | description |
任务详细描述。
1 | status |
任务状态:
1 | pending |
当前由谁负责。
1 | blockedBy |
当前任务被哪些任务阻塞。
例如:
1 | { |
表示 API 任务必须等数据库 schema 完成后才能开始。
4. 文件持久化:.tasks/{id}.json
每个 task 都会保存成一个 JSON 文件:
1 | .tasks/ |
这样即使程序退出,下次启动也可以重新读取任务状态。
这就是 Task System 和 TodoWrite 最大的区别:
Task System 是持久化的。
5. create_task:创建任务
1 | def create_task( |
创建任务时会:
- 生成 task id;
- 设置标题;
- 设置描述;
- 默认状态为
pending; - owner 为空;
- 设置依赖;
- 写入
.tasks/{id}.json。
6. can_start:判断任务能不能开始
不是所有 pending 任务都能开始。
如果它依赖的任务还没完成,它就不能 claim。
1 | def can_start(task_id: str) -> bool: |
逻辑很简单:
- 如果依赖任务不存在,不能开始;
- 如果依赖任务没完成,不能开始;
- 所有依赖都 completed,才能开始。
这就是 DAG 任务依赖的基础。
7. claim_task:领取任务
当 Agent 准备处理一个任务时,需要 claim:
1 | def claim_task(task_id: str, owner: str = "agent") -> str: |
它会做三步检查:
第一,任务必须是 pending。
如果已经 in_progress 或 completed,就不能 claim。
第二,依赖必须完成。
如果 blockedBy 里有任务没完成,就返回 blocked。
第三,通过后设置:
1 | task.owner = owner |
然后保存。
8. complete_task:完成任务并解锁下游任务
任务完成后:
1 | def complete_task(task_id: str) -> str: |
它除了把当前任务设为 completed,还会扫描所有任务,找出刚刚被解锁的任务。
比如原来任务关系是:
1 | schema → endpoints → tests |
完成 schema 后:
1 | endpoints unlocked |
完成 endpoints 后:
1 | tests unlocked |
这就是任务图的推进。
9. get_task:查看完整任务
1 | def get_task(task_id: str) -> str: |
list_tasks 一般只看摘要。
get_task 用来读取完整任务信息,尤其适合跨会话恢复时使用。
10. 状态机
s12 的任务状态机很简单:
1 | pending ──claim──→ in_progress ──complete──→ completed |
也就是:
pending:还没人做;in_progress:已经被领取,正在做;completed:已经完成。
对应动作:
claim_task:pending → in_progress;complete_task:in_progress → completed。
教学版没有实现:
1 | in_progress → pending |
也就是没有 release / rollback。
真实系统里,如果某个 agent 崩了,可能需要把它未完成的任务重新释放给别人。
但教学版为了简单,没有加这个恢复路径。
11. 五个 Task 工具
s12 把任务系统暴露为工具:
1 | create_task |
它们进入工具表:
1 | TOOL_HANDLERS["create_task"] = create_task |
这样模型就可以自己:
- 创建任务;
- 列出任务;
- 查看任务详情;
- 领取可开始的任务;
- 完成任务并解锁下游。
12. 一个完整例子
假设用户说:
1 | 创建任务:搭数据库、写 API、写测试、写文档。 |
任务关系是:
1 | setup database schema |
创建任务:
1 | schema = create_task("setup database schema") |
开始执行:
1 | claim_task(schema.id) |
此时解锁:
1 | create API endpoints |
继续:
1 | claim_task(endpoints.id) |
此时解锁:
1 | write tests |
最后:
1 | claim_task(docs.id) |
这个流程就保证了:
不会在数据库没建好之前先写 API,也不会在 API 没完成前先写测试。
13. s12 的核心价值
s12 解决的是项目级任务管理问题。
以前 TodoWrite 是:
1 | 当前会话里的临时 checklist |
现在 Task System 是:
1 | 跨会话、可恢复、带依赖、可领取的任务图 |
它让 Agent 可以处理更长周期的项目任务。
一句话概括:
TodoWrite 管执行步骤,Task System 管项目进度。
四、三章合起来的 Harness 构造顺序
把 s10、s11、s12 串起来,可以得到一个更工程化的 harness。
第一步:把硬编码 SYSTEM 拆成 PROMPT_SECTIONS
1 | PROMPT_SECTIONS = { |
第二步:用 context 表示真实运行状态
1 | context = { |
第三步:运行时组装 system prompt
1 | system = get_system_prompt(context) |
如果 context 没变,就走缓存。
如果 context 变了,就重新:
1 | assemble_system_prompt(context) |
第四步:用 try/except 包住 LLM 调用
1 | try: |
这样 Agent 不会因为普通 API 错误直接崩。
第五步:处理 max_tokens 截断
1 | if response.stop_reason == "max_tokens": |
这一步解决输出被截断的问题。
第六步:为 429 / 529 做指数退避
1 | def retry_delay(attempt): |
这一步解决临时失败问题。
第七步:创建 .tasks/ 目录
1 | .tasks/ |
每个任务一个 JSON 文件。
第八步:定义 Task 数据结构
1 | @dataclass |
第九步:实现任务工具
1 | create_task |
然后注册到:
1 | TOOL_HANDLERS |
第十步:Agent 使用任务系统推进项目
完整流程是:
1 | 用户提出大目标 |
五、最终 Agent Harness 流程
整合后,一轮完整流程可以理解为:
1 | 更新 context |
六、总结
这三个章节把 Agent harness 从“功能可用”进一步推向“工程可用”。
s10 解决 prompt 维护问题:
system prompt 不再硬编码,而是根据运行状态动态组装。
s11 解决运行稳定性问题:
API 报错、上下文过长、输出截断,都进入对应恢复路径。
s12 解决项目任务管理问题:
大目标被拆成持久化任务,任务之间有依赖,可以跨会话恢复。
所以这三个阶段的核心关系是:
1 | System Prompt 让 Agent 知道自己是谁、有什么能力; |
如果说前面的章节是在教 Agent “怎么行动”,那么这三章是在教 harness “怎么管理一个长期运行的 Agent”。
一句话概括:
真正工程化的 Agent,不只是会调用工具,还要能动态组装上下文、从错误中恢复,并用任务系统管理长期目标。
