Lazy loaded image
runtime轮子项目04丨Agent 怎么知道自己没有跑偏
字数 2862阅读时长 8 分钟
2026-1-11
type
Post
status
Published
date
Jan 11, 2026
slug
agent04
summary
tags
Agent
category
icon
password
我做这个 mini runtime 时,一开始也容易把“连续任务”想成长期记忆:让模型记住更多历史,它就更像一个能长期工作的工程师。往下做才发现,V1 更急的是当前这一个 repo 任务别断线:模型刚刚读了什么、改了什么、测了什么、为什么停下来,这些状态要能接住下一步。连续任务的第一步不是记住更多,而是别把当前任务做丢。
任务没有跑飞,靠状态把每一步接住。

先看一条小 bugfix 怎么走完

如果直接讲 TaskSession、snapshot、timeline 很容易抽象,先看一条路径会更容易明白这些状态在接什么。
我用的例子是 demo 里的 slugify_title():模型先读目标文件,再跑出失败测试,然后请求 patch;patch 不会直接落地,而是进入审批;审批通过后 repo 发生变更,diff 更新;最后再跑一次测试,测试通过后才允许 finish。把它压成一条线,就是下面这样:
task_received plan_mode_entered plan_updated plan_mode_exited tool_executed: read_file local_test_completed: exit_code=1 approval_requested: file_patch approval_granted: file_patch repo_state_mutated diff_updated local_test_completed: exit_code=0 agent_loop_stopped: finished
任务不断线,体现在每一步都有下一步。
这条线里每一步都需要被接住:read_file 之后要记住最近读过的文件,失败测试之后要记住失败输出,patch 之后要记住 diff,测试通过以后要记住它对应的是哪一版 repo 状态。到这里再看 TaskSession、latest_tool_result、latest_diff、timeline 这些字段,它们就不是一串名词了,而是在接住这条 bugfix 路径上的不同节点。

我先把“连续任务”收窄到当前任务

我现在会刻意把“连续任务”这个词说窄一点。这里的连续,不是服务重启以后自动恢复,不是 checkpoint / replay,也不是把很多天以前的经验塞进长期记忆。当前 V1 解决的是更小也更硬的一件事:同一个 task 在一次 session 里,能不能沿着计划、工具结果、diff、测试和停机原因继续往下走。这一版先解决的是当前任务不断线。
你说 session,是聊天记录吗?你说恢复,是不是服务挂了也能回来?我会直接回答:不是,这里还没有服务重启后的自动恢复;现在已经完成的是内存态 TaskSession,它能把一次任务里的关键状态都留下来,让下一步决策和人类审查有东西可看。

TaskSession 把任务状态装起来

为什么?因为 agent 每一步能不能接上,要看运行时有没有地方保存上一步结果。
TaskSession 在这个项目里不是一个普通的请求上下文。它更像一次 repo 任务的工作台:任务输入放在这里,plan 和 todos 放在这里,最新一次工具结果放在这里,pending approval 也放在这里。它保存的是“下一步还能不能接着干”的依据。session 是任务现场,不是聊天历史。
我最在意的是 snapshot() 这层。它把当前 session 压成一个可以给 API、agent prompt、Web 控制台共同消费的状态视图。这样做以后,系统里很多地方不用各自猜“现在任务到哪了”,而是统一看同一份 snapshot:当前是不是还在 plan mode,哪个 todo 还在推进,最新工具结果是什么,最新 diff 有没有变化,还有没有 pending approval。snapshot 让任务状态有了一个共同版本。
{ "permission_mode": "default", "todos": [ {"content": "Patch slugify_title", "status": "in_progress"} ], "latest_tool_result": { "tool_name": "file_patch", "status": "executed" }, "latest_diff": "... demo_app/string_tools.py ...", "latest_successful_test": null, "pending_approvals": [], "timeline": [ {"event_type": "task_received"}, {"event_type": "plan_mode_entered"}, {"event_type": "tool_executed"}, {"event_type": "diff_updated"} ] }
这个小 snapshot 看起来不复杂,但它解决的是很实际的问题。比如模型刚 patch 完,下一步到底该继续改,还是该跑测试?如果只看模型输出,答案会很飘;如果看 snapshot,latest_diff 已经有变更,latest_successful_test 还是空,这时就不该 finish,下一步应该往 run_test 走。下一步决策要看状态,不该只听模型自述。

最关键的一步:旧测试必须过期

为什么?因为 coding agent 最危险的收尾方式,就是模型说“完成了”,系统也跟着信了。
我一开始差点把 finish 想简单了:模型 patch 完以后说“完成”,如果系统只检查有没有 diff,很容易就放过去。再往下做我才把注意力收到 latest_successful_test 上:只有当前 repo 状态真的跑过成功测试,session 才有资格认为任务可以收尾。finish 要看测试状态,不能只看模型说的过了。
这里最关键的字段是 repo_state_revision。说白了,它就是当前 repo 状态的版本号;每次 write_file 或 file_patch 真的写入,revision 就加一,之前的成功测试立刻清空。这样一来,latest_successful_test 记录的就不是“刚才某个时刻测试通过了”,而是“当前这份 repo 状态测试通过了”:
run_test -> exit_code 0 latest_successful_test.repo_state_revision = 1 file_patch -> repo_state_revision = 2 latest_successful_test = null finish -> blocked or repaired to run_test
最典型的失败场景是这样:测试刚刚通过,模型又做了一次 file_patch,然后继续说 finish。如果没有 revision 这层,系统很容易拿旧测试结果当新状态的依据;现在这一步会直接把 latest_successful_test 清掉,模型再说 finish,runtime 也会把它拉回 run_test。对应回归是 test_successful_test_is_bound_to_current_repo_state。这不是“测试过了”的判断,而是“当前状态测试过了”的判断。

latest_tool_result 和 latest_diff 让下一步有落点

任务最容易跑飞的时候,往往就是系统不知道刚刚那一步到底产生了什么。
我一开始也觉得工具执行完返回给模型就够了。再往下做才发现,工具结果如果只停留在一次 response 里,后面很多东西都会断掉:Web 控制台看不到最后发生了什么,agent prompt 也没法稳定带上最近失败原因,用户更难判断现在该批准、重试还是收尾。所以我把最近一次工具执行结果固定进 latest_tool_result。最新工具结果必须成为 session 状态,而不是一次性消息。
latest_diff 也是同样的思路。只要写文件或 patch 真的改了 repo,session 就刷新当前 diff,并在 timeline 里记一条 diff_updated。这样用户不需要从一堆工具日志里翻“到底改了什么”,Web 控制台也能直接展示当前可审查 diff。对一个 coding agent 来说,这件事很关键,因为最终要交给人的不是一段解释,而是一份能审查的变更。
这里有一个我后来才越来越在意的小取舍:latest_tool_result 和 latest_diff 不只是给人看的,也会反过来影响下一步 prompt。比如测试失败以后,context bundle 会带上最近测试失败;patch 以后,snapshot 会告诉下一步“当前已经有 diff”。这让 agent loop 不再像无头苍蝇一样每轮重新猜任务进度。可观测状态本身也会变成下一步执行能力。

timeline 让任务能被读回去

只看最终 diff的话,很难解释任务为什么走到这里。
timeline 是我在这个项目里越做越觉得有用的东西。它不是为了把所有日志都存下来,而是为了让我能把一次任务现场读回去:它什么时候进入 plan mode,什么时候读文件,什么时候测试失败,什么时候请求审批,什么时候真的改了 repo,最后为什么停。timeline 的价值是让任务过程能被复述。
我现在读 timeline,是顺着 5 个问题核对:它读过目标文件吗?失败测试出现过吗?写入有没有经过审批?repo 有没有产生 diff?写完以后有没有再跑成功测试?这几件事连起来,才说明任务没有只是“说自己做完了”。最小可观测性要先让人看懂关键路径。
回到开头那条 demo bugfix,timeline 能先排掉一个误判:agent 只是最后给了一个看起来合理的答案。因为它能看到模型有没有读文件、有没有跑失败测试、有没有经过审批、有没有产生 diff、有没有再跑成功测试。它不需要解释所有内部想法,只需要把外部可验证的关键动作留下来。timeline 不追求读心,只追求把可验证动作串起来。

观察窗口

Web 控制台只是一个观察窗口,把 session snapshot、latest tool result、latest diff、latest successful test 和 timeline 摊开;它可以让人看见 plan mode、todo progress、pending approval、当前 diff 和测试状态,但不负责决定 agent 能不能写、能不能测、能不能 finish。Web 控制台展示状态,不替 runtime 做判断。
这篇讲的点
仓库里能看到的机制
重点概括
当前任务不断线
TaskSession、snapshot()、plan、todos、pending approvals
先把当前任务接住,不先吹重启恢复
下一步有依据
latest_tool_result、latest_diff、recent context
工具结果和 diff 要回到 session
过程能读回去
timeline、tool_executed、diff_updated、local_test_completed
人能按事件读回任务现场
finish 不是瞎 finish
latest_successful_test、repo_state_revision
测试必须对应当前 repo 状态
人能审查任务现场
Web 控制台消费 snapshot、diff、test、timeline
UI 是观察窗口,不是决策层

我现在怎么理解“Agent 怎么知道自己没有跑偏?”

这个 mini runtime 为什么能让任务不跑偏?我会从 session 讲起,而不是从模型能力讲起。模型每一步当然重要,但系统接住任务靠的是这些状态:snapshot 让下一步知道现在到哪了,latest_tool_result 让最近一次动作不丢,latest_diff 让变更可审查,timeline 让过程能读回去,latest_successful_test 和 repo_state_revision 让收尾有依据。agent 能连续做事,是因为每一步都留下了下一步能用的状态。
把这件事想清楚以后,我对 agent 连续性的判断也变简单了:它当然可以继续往长期记忆、checkpoint、replay 演进,但 V1 先要把当前任务的状态链做稳。否则模型看起来还在继续说话,系统其实已经不知道它走到哪一步了。一个 agent 能连续做事,靠每一步状态都不断线。
 
回到首页