拨开迷雾看本质:Agent plantask 的架构推导
在 Eino 的 Agent 体系中,单个 Agent 可以通过 ReAct 不断迭代,但当任务复杂到一定程度(比如开发一个带有前后端和数据库的完整 Feature)时,纯靠 LLM 脑子里的 Context 会发生严重的"幻觉"和"遗忘"。
这时候,我们需要引入计划与任务管理(Plan & Task) 。这正是 adk/middlewares/plantask 包存在的意义。
1. 寻找"第一性原理":最朴素的任务记录
如果没有任何框架,一个开发者在接手复杂任务时,最朴素的做法就是建一个 TODO.md:
markdown
// 第一性原理:朴素的 TODO 列表
1. [ ] 搭建数据库表结构
2. [ ] 编写 CRUD 接口
3. [ ] 联调测试
对于 Agent 来说,如果我们让它自己维护这个列表,它只能把这个列表塞进自己的 Messages 上下文里。每次调用模型,都要带上这串文本。
致命痛点:
- Context 爆炸与遗忘:随着任务推进,TODO 列表越来越长,修改记录越来越多,LLM 的上下文窗口会被撑爆,且极其容易在多轮对话中"改错"或"遗忘"某些任务状态。
- 多 Agent 无法协作:如果我想让 Coder Agent 去做任务 1,让 Tester Agent 去做任务 2,由于 TODO 列表只存在于主 Agent 的脑子里,子 Agent 根本不知道全局进度。
2. 第一次演进:存储外置与工具化(CRUD 范式)
推导解决 :把"脑子里的记忆"剥离出来,变成"外部的持久化存储"。并且,给 Agent 提供一组专门操作这个存储的 工具(Tools)。这就变成了标准的 CRUD。
go
// 第一次演进:工具化的 CRUD 任务管理
type Task struct {
ID string
Status string // pending, in_progress, completed
Title string
}
// 提供给大模型的工具箱
func TaskCreate(title string) { db.Save(&Task{Title: title}) }
func TaskList() []Task { return db.FindAll() }
func TaskUpdate(id, status string) { db.Update(id, status) }
这就是 adk/middlewares/plantask/plantask.go 中 BeforeAgent 注入的四个工具的本质:
它在 Agent 执行前,硬塞了四个 Tool:TaskCreate、TaskGet、TaskUpdate、TaskList。
映射到源码:
- task.go 定义了
task结构体。 - task_create.go / task_update.go 等实现了具体的工具逻辑。
3. 第二次演进:文件系统后端的妥协(The Filesystem Backend)
新痛点 :上面推导的 db.Save() 只是个概念。在真实的云原生或沙盒环境中,这个 db 应该是什么?
如果我们用 MySQL 或 Redis,就会引入极重的外部依赖,让框架变得难以本地测试和部署。
推导解决 :利用 Eino 已有的 filesystem 抽象(见 adk/middlewares/filesystem),把每个 Task 存成一个独立的 JSON 文件!
go
// 第二次演进:基于文件系统的 Task 存储
func TaskCreate(title string) {
id := getNextID() // 读取 .highwatermark 文件
content := toJSON(&Task{ID: id, Title: title})
fs.Write(fmt.Sprintf("%s.json", id), content)
}
映射到源码:
- task.go 定义了
Backend接口,它直接借用了filesystem.ReadRequest等结构。 - task_create.go 展示了典型的文件锁与自增 ID 的实现方式:通过读取
.highwatermark文件来获取下一个 Task ID,并将其保存为<ID>.json。
4. 第三次演进:复杂的依赖图拓扑(Blocks / BlockedBy)
新痛点 :简单的列表只适合单线程、无脑顺序执行的工作。但在真实项目中,"写接口(Task 2)"必须在"建表(Task 1)"之后完成;而"写文档(Task 3)"可以和"写接口"同时进行。
如果只有一个大模型在跑,它确实可以自己决定先做谁再做谁。但如果是多 Agent 协作网络(比如通过 Workflow 启动了 3 个平行的 Worker Agent),它们都在看着同一块任务看板:
- 无序的灾难:如果没有依赖关系,Worker B 可能会抢到"写接口",但此时 Worker A 的"建表"还没做完,Worker B 直接报错。
- 动态规划的需求 :任务并不是在一开始就全部排好序塞进队列的!大模型在执行"建表"时,可能会动态发现新问题并创建新任务 ,这就要求任务系统不能是一个死板的静态队列,而必须是一个支持动态修改的有向无环图(DAG)。
推导解决 :必须在 Task 模型中引入拓扑依赖关系,即 Blocks(我阻塞了谁)和 BlockedBy(谁阻塞了我)。
- 当 Agent 调用
TaskList时,它会主动过滤掉那些len(BlockedBy) > 0(前置任务未完成)的任务。 - 当 Agent 动态添加依赖时(比如发现任务 C 必须依赖任务 A),必须严格校验环形依赖(防止出现 A 等 B,B 等 C,C 等 A 的死锁)。
go
// 第三次演进:防环的依赖管理 (DAG)
type Task struct {
Blocks []string // 我阻塞了谁
BlockedBy []string // 谁阻塞了我
}
func UpdateTaskDeps(taskId, blockedById string) error {
// 每次动态修改依赖图时,必须检查是否成环
if hasCyclicDependency(taskId, blockedById) {
return error("环形依赖!")
}
// 双向更新边
task[taskId].BlockedBy.append(blockedById)
task[blockedById].Blocks.append(taskId)
}
映射到源码:
- task.go 并没有进行全局的拓扑排序(不需要排成一个线性队列),而是实现了一个用于插入依赖时防环 的 DFS(深度优先搜索)算法
hasCyclicDependency。因为只要图里没有环,各个 Agent 就可以靠局部视角(看谁的BlockedBy是空的)安全地拉取任务。 - task_update.go 实现了复杂的双向依赖更新逻辑。当大模型通过工具调用
AddBlockedBy时,不仅更新当前任务文件,还要去修改被阻塞任务的Blocks数组。
5. 第四次演进:API 化与防遗忘的主动唤醒(The Watchdog)
随着在真实 Agent Teams 场景中的落地,纯靠 LLM 自主维护看板暴露了两个致命问题。
痛点 1:LLM 的沉浸式遗忘
在复杂的编码任务中,大模型经常会在连续地改代码、运行测试中"上头"。如果它连续 10 多轮对话都没有调用 TaskUpdate 更新进度,整个系统的看板状态就会停滞,其他协作 Agent 也会被卡死。这被称为**"沉浸式遗忘"**。
推导解决 1:状态机的看门狗(Task Reminder)
必须把"更新进度"这个动作,从被动等待 LLM 觉醒 ,变成框架层的主动提醒 。
利用 Middleware 钩子(BeforeModelRewriteState),在每次调用大模型前,像看门狗一样扫描 Messages 历史:
- 动作 :向前追溯历史消息,计算自上次调用
TaskCreate/TaskUpdate工具以来,经过了多少个 Assistant 轮次。 - 唤醒 :如果超过阈值(如默认 10 轮),框架会强行在上下文末尾注入一条
<system-reminder>消息,不仅包含温和的提示语,还会动态拼装上当前的最新任务列表。 - 映射到源码 :task_reminder.go#L56-L104 实现了
computeTurnStats用于统计轮距;task_reminder.go#L134-L214 实现了拦截和动态提示词注入。这成功地将 LLM 从"局部细节"拉回"全局进度把控"。
痛点 2:框架与 Agent 的单向隔离
早期的 CRUD 仅仅是作为 Tool 暴露给大模型的。如果外部宿主系统(Host Code)想要在 Agent 启动前预置初始任务 ,或者通过人类干预删掉一个错误任务,缺少开放的编程接口。
推导解决 2:剥离逻辑的编程接口(Programmatic API)
将任务修改的底层能力与 Tool Wrapper 解耦,提供纯 Go 调用的 CreateTask 和 DeleteTask。
- DAG 的自愈能力 :最典型的体现是
DeleteTask的实现。如果强行删掉一个任务节点,它的 ID 还会残留在其他任务的BlockedBy数组里,导致其他任务永远无法解锁(死锁)。因此,task_api.go#L143-L188 必须遍历整个系统的所有任务,将这个被删节点的 ID 从所有Blocks和BlockedBy数组中安全剔除,保证图拓扑的完整性。
6. 批判性总结
优势:将"思考"降维为"工程"
- 彻底解放 Context :大模型不需要在脑子里记住几百行的 TODO 列表。它只需要通过
TaskList扫一眼,找到status: pending且没有blockedBy的任务,然后TaskGet看详情,干完后TaskUpdate标记完成。这极大地降低了长链路任务的幻觉率。 - 天然适配多 Agent 并发 :因为状态被外置到文件系统中,配合
Owner字段(认领机制),多个 Agent 完全可以像人类工程师看 Jira 面板一样,异步地抢单、干活、交付。
代价与局限:沉重的锁与碎文件地狱
- 全局大锁的悲哀 :在 task_create.go 和其他几个工具中,我们可以看到
t.lock.Lock()。因为底层依赖的是文件系统(特别是读取.highwatermark自增 ID),为了防止并发创建任务导致 ID 冲突,整个 Task 系统的写入是被强制串行化的。 - 碎文件性能瓶颈 :每次
TaskList(见 task_list.go),都要LsInfo扫目录,然后在一个for循环里去Read并Unmarshal每一个.json文件。如果一个项目有 100 个 Task,每次 List 都要进行 100 次 IO 读取!这在文件系统上是极度昂贵的。 - 一致性危机 :在
TaskUpdate更新双向依赖时,它需要修改两个不同的 JSON 文件。但文件系统是没有**事务(Transaction)**的!如果改了第一个文件,在改第二个文件时进程崩溃了,整个任务依赖图就彻底撕裂了。
7. 生态站位:它与 Supervisor / PlanExecute 的关系
一个非常关键的问题是:Agent 怎么认领任务?这个中间件需要依赖特定的框架模式(如 DeepAgent / Supervisor / PlanExecute)吗?
答案是:不需要。它是一个纯粹的"底层工具提供者(Provider)",它完全不关心是谁在用它。
7.1 它到底提供了什么?
plantask 中间件的本质,仅仅是向挂载了它的 Agent 的 Tools 列表里,硬塞了四个外部工具 (CRUD)。
它没有 提供任何路由逻辑,没有提供任何"调度循环",它甚至不知道自己在一个什么架构里运行。
Agent 是怎么认领任务的?
完全靠大模型(LLM)的自主推理(ReAct)。
- 框架层面没有任何"分发(Dispatch)"代码。
- 而是 LLM 看到自己手里有一个
TaskList工具,它主动调用。 TaskList会把所有任务(包含 pending、completed 以及带有 blockedBy 的任务)的简要信息返回给模型。- LLM 阅读这些返回的文本后,自己进行逻辑判断:"这个 Task 1 是 pending 的,并且它的 blockedBy 是空的,所以我可以做"。于是它主动调用
TaskUpdate(id=1, owner="self", status="in_progress")。
7.1.1 过滤过滤(Filter)vs 暴露(Expose):Eino 为什么选择让模型自己判断?
在这里有一个经典的设计分歧:当调用 TaskList 时,到底应该在代码层面过滤掉 不可做的任务(只返回真正可执行的任务),还是把所有任务的状态都暴露给大模型,让大模型自己判断?
Eino 选择的是后者(暴露给大模型判断) 。参见 task_list.go,它的返回逻辑只是简单地把所有 Task 拼成字符串:
text
#1 [completed] 搭建数据库表结构 [owner: agent-a]
#2 [pending] 编写查询接口 [blocked by #1]
#3 [pending] 编写 API 文档
为什么这种设计更好?
- 全局视野与规划能力 :如果框架直接把
#2过滤掉,大模型就成了"瞎子",它只知道自己现在能做#3,完全不知道未来还有个#2需要做。这会破坏大模型对整个项目的全局上下文理解 (比如它可能在写#3文档时,需要预判#2接口的设计)。 - 信任智能涌现 :早期的低智模型(如 GPT-3)可能会无视
[blocked by]标签强行接单,所以老一代的框架倾向于在代码里做死卡(Hard Filter)。但现代模型(GPT-4/Claude 3)完全能够理解blocked by的语义。把决策权交给模型,体现了"让模型做大脑,代码只做手脚"的架构哲学。 - 减少隐式魔法 :如果工具名叫
TaskList,却暗中过滤了部分数据,这是对接口语义的破坏。如果需要过滤,应该提供另一个名为GetAvailableTasks的工具,而不是在TaskList里做手脚。
7.2 各种模式下如何使用 plantask?
因为它是纯底层的工具集,所以它可以被随意插拔到任何模式中,产生不同的化学反应:
- 单体单线程 ReAct(最简单)
- 一个人包揽所有。LLM 自己列 Plan,自己做,做完一个自己打钩。此时它主要用来解决"Context 遗忘"问题,充当"外挂记忆"。
- Supervisor 模式(包工头与打工人)
- 可以让 Supervisor Agent 挂载
TaskCreate和TaskList(它只负责拆解任务、看进度)。 - 让底下的 Specialist Agents 挂载
TaskGet和TaskUpdate(它们只负责接活、干活、改状态)。 - 此时,看板成了上下级之间通信的桥梁,代替了臃肿的上下文传递。
- 可以让 Supervisor Agent 挂载
- PlanExecute 模式的升维(内置 Plan vs 外挂 PlanTask)
- 在
adk/prebuilt/planexecute中,Eino 提供了一个内置的轻量级 Plan 机制(把步骤写在Messages里,靠图循环执行)。但如果把它与plantask中间件对比,会发现它们在存储介质、执行拓扑和协作能力上存在巨大的代差:
- 在
| 维度 | PlanExecute 内置 Plan | plantask 外挂中间件 |
|---|---|---|
| 存储介质 | 内存(Context 数组) Plan 存在大模型的 Messages 历史记录里。 |
持久化(文件系统/DB) Plan 存在外部的 JSON 文件里,大模型通过工具去读。 |
| 执行拓扑 | 线性队列(Queue) 只能一步步串行往下做(Step 1 -> Step 2 -> Step 3),不支持复杂的条件分支和依赖。 | 有向无环图(DAG) 支持 Blocks 和 BlockedBy,可以表达"任务 A 和 B 必须先做完,才能做 C"这种复杂的非线性依赖。 |
| 协作能力 | 单机单线程 只有当前那个 Executor 知道 Plan 是什么,无法让多个 Agent 一起干活。 | 分布式多机协同 看板是公开的,支持引入 Owner 字段。完全可以启动多个不同职能的 Agent 并发抢单干活。 |
| 抗遗忘与容错 | 极易幻觉 随着任务执行,Context 越来越长,大模型很容易忘记最初的 Plan 甚至"自己篡改"历史。一旦崩溃,除非有外部 Checkpoint,否则全丢。 | 绝对准确 每次干活前都通过 TaskGet 现查文件,文件里写了什么就是什么。进程崩溃重启后,直接读目录就能恢复所有状态。 |
什么时候用谁?
- 如果任务是 "写个简单的 Python 爬虫" (3-5 个步骤),直接用
PlanExecute内置的 Plan,轻量快捷,不需要额外的文件系统 IO 开销。 - 如果任务是 "从零开发一个带前后端的电商网站" (包含几十个步骤、复杂的接口依赖、需要前后端 Agent 协作),必须抛弃内置 Plan,引入
plantask。让 Planner Agent 专门负责调TaskCreate画出庞大的 DAG 图,然后让多个 Executor Agent 作为 Worker 盯着看板疯狂清空任务。
总结 :plantask 不是一种独立的 Agent 拓扑,而是一块大家都能看到并修改的"共享白板" 。它将多 Agent 之间的点对点通信(RPC/Transfer) ,降维成了基于共享存储的异步协同(Pub/Sub)。
更优解的探讨
基于文件系统存储 JSON 是为了"轻量"和"便携"(特别是为了配合 Sandbox 环境),但它牺牲了关系型数据的核心特性。
更好的做法是:
- 既然是中间件,就应该把存储层彻底抽象为
Repository接口,提供SQLite/BoltDB的本地实现作为默认选项。 - 一张表存 Task,一张表存 Edge(依赖关系),这样不仅能利用 SQL 的事务保证一致性,还能把
TaskList的时间复杂度从 O ( N × I O ) O(N \times IO) O(N×IO) 降到真正的 O ( 1 ) O(1) O(1) 级查询。