目录
1. Todo 模块的职责
2. 如何理解 Todos
它解决什么问题、为什么值得用
3. Todos 的生命周期
3.1 默认守则如何指挥模型动手
3.2 状态与条目的生命周期
3.3 每轮自动注入清单
4. 类型清单
5. 相关类型解析
5.1 TodoProvider
5.2 TodoProviderOptions
5.3 TodoItem
5.4 内部类型
6. 暴露给模型的工具
7. 行为要点
8. 与其它模块的关系
9. 扩展与最佳实践
10. 小结
上一篇
基于 MAF .NET
1.13.0,实验性 API(MAAI001)
程序集:Microsoft.Agents.AI(核心包) · 源码目录:dotnet/src/Microsoft.Agents.AI/Harness/Todo/
1. Todo 模块的职责
给智能体一份会话级待办清单,让它在执行多步复杂任务时能把任务拆成可追踪的条目、逐项完成、随时增删。它本质上是一个AIContextProvider:每次调用前往系统指令里注入"怎么用待办清单"的守则,并把 5 个待办操作工具暴露给模型;同时在每轮调用开头注入一条合成消息,把当前待办列表"念给模型听",确保它始终知道还有哪些事没干完。
待办状态存在会话状态袋(AgentSessionStateBag)里,跨同一会话的多次调用保持;不同会话互相隔离。
2. 如何理解 Todos
把 Todos 想象成智能体随手贴在桌面上的一叠便利贴清单——接到一个多步骤的活儿时,它先把活儿拆成一条条“待办”写下来,干完一条划掉一条;你随时能凑过去看它写了什么、还剩几条没干。
举个例子。你对一个研究助理说:
“帮我调研 A、B、C 三家云厂商的 Serverless 冷启动延迟,最后给一份对比报告。”
这是个典型的多步骤任务。智能体不会闷头一次写完,而是(在默认守则引导下)先判断“这活儿复杂”,于是调todos_add把它拆成一串待办:
1 [open] 调研 A 厂商冷启动延迟 2 [open] 调研 B 厂商冷启动延迟 3 [open] 调研 C 厂商冷启动延迟 4 [open] 汇总对比、撰写报告接下来它一条条推进:查完 A 就调todos_complete把 #1 划成 done,再往下走。你中途插一句“顺便加上 D 厂商”,它就调todos_add添一条;你要是改主意说“算了,不看 C 了”,它会调todos_remove把 #3 删掉。(如果你跑起了 Harness 的 Console 示例,输入/todos就能看到这份清单的实时状态)
反过来,如果你只是问一句“现在几点”这种一步就能答的简单问题,智能体不会建待办——默认守则明确要求它先分辨“复杂 vs 简单”,简单的直接做,别为难自己搞一堆清单。
它解决什么问题、为什么值得用
大语言模型的“记性”受上下文窗口限制,长任务里很容易忘掉前面定好的步骤、漏掉某一环、或跑着跑着跑偏。Todos 的本质是把计划从“对话里的一段话”外化成一份结构化、会持久化的状态,好处有四:
不掉步、不跑偏:每轮调用开头,MAF 都会把当前清单重新“念”给模型(一条合成 user 消息),它始终清楚“还剩哪些没干”。
天然支持“先规划、后执行”:把一个模糊的大请求变成可确认、可追踪的计划——配合
AgentMode的 plan / execute 就是一条完整的规划闭环。抗上下文压缩:待办存在会话状态袋里,不是普通对话消息,因此不会被 Compaction 当作旧消息压缩掉;哪怕历史被砍,计划还在。
可自主、可观测:Loop 能读它判断“是否全部干完”来决定要不要再跑一圈(无人值守续跑);宿主代码 / UI 也能读它给人看实时进度。
一句话:Todos 让智能体从“一次性尽力而为”变成“有计划、能追踪、可恢复、看得见”。
3. Todos 的生命周期
Todos 的“创建 → 改状态 → 删除”全部由模型的工具调用驱动,而“每轮把清单念给模型”则由MAF 自动注入。下面按时间线拆开。
3.1 默认守则如何指挥模型动手
TodoProvider注入的DefaultInstructions决定了模型“何时该建 / 改 / 删”:
先判断任务是复杂(多步)还是简单(一步)。
复杂 → 拆成待办、
todos_add进清单;简单 → 不建待办,直接做。需要澄清时先问用户,再据此建待办。
用户对计划有反馈 → 增删条目调整;用户切换话题 / 改主意 → 移除无关项、清空或重建清单。
执行中随手
todos_complete标记完成、todos_remove删掉不再需要的。
3.2 状态与条目的生命周期
阶段 | 触发者 | 发生了什么 |
|---|---|---|
状态创建 | 首次访问 | 第一次调用 Provider / 工具时, |
条目创建 | todos_add | 每条分配 |
状态变更 | todos_complete | 按 ID 把未完成项的 |
条目删除 | todos_remove | 按 ID 批量删除;删掉 ≥1 条才写回。ID 不回收( |
清空 | todos_remove | 没有专门的 clear 工具——“清空”就是模型把条目全删掉;底层 |
状态销毁 | 会话结束 | 待办状态随会话生命周期存在,跨同一会话多次调用一直保持;MAF 不做自动过期 / 清理。会话被丢弃时状态随之消失,不同会话相互隔离 |
3.3 每轮自动注入清单
每次调用前,MAF 把当前清单格式化成一条合成 user 消息注入(形如### Current todo list加上- {id} [open/done] {title}: {desc},空清单则为- none yet),受SuppressTodoListMessage/TodoListMessageBuilder控制。这一步不增删条目,但它是“模型每轮都记得清单”的关键。
关键点:清单不会“自动清理”。一条待办从建立到消失,中间每一次状态变化都对应模型的一次显式工具调用;MAF 只负责持久化和每轮提醒,不替模型做增删决策。
4. 类型清单
类型 | 可见性 | 种类 | 职责 |
|---|---|---|---|
TodoProvider | public | AIContextProvider, | 模块主体:注入指令、暴露工具、维护状态 |
TodoProviderOptions | public | 配置类 | 自定义指令、是否注入清单消息、清单消息格式 |
TodoItem | public | 数据模型 | 单个待办项(Id / Title / Description / IsComplete) |
TodoState | internal | 会话状态 | 持有 |
TodoItemInput | internal | 工具入参 | todos_add的入参(Title / Description) |
TodoCompleteInput | internal | 工具入参 | todos_complete的入参(Id / Reason) |
5. 相关类型解析
5.1 TodoProvider
模块主体,继承AIContextProvider、实现IDisposable。核心职责有三:
注入上下文:覆写的
ProvideAIContextAsync返回一个AIContext,里面装着待办使用守则(Instructions)、5 个工具(Tools),以及(默认情况下)一条合成 user 消息——把当前待办列表格式化后注入,让模型每轮开头就看到"还剩哪些没干"。维护状态:通过
ProviderSessionState<TodoState>在会话状态袋里申请一个独立 key 存取TodoState。线程安全:所有读写都用"每会话一把锁"(
SemaphoreSlim,按AgentSession用ConditionalWeakTable缓存;无会话时用一把兜底锁)序列化,避免并发产生重复 ID、丢更新或脏读。
它还对外暴露两个公开方法,供宿主代码绕过模型直接读状态(官方示例的/todos控制台命令即用此实现):
GetAllTodosAsync(session, ct)—— 取全部待办(含已完成)。GetRemainingTodosAsync(session, ct)—— 只取未完成的。
注意:这两个方法返回的是内部状态里的活引用(live reference),改它们的属性会直接改动 Provider 状态。
5.2 TodoProviderOptions
控制TodoProvider行为的可选配置:
Instructions(string?)—— 整段替换默认注入的待办守则。默认守则会教模型"先判断任务复杂度,复杂才拆 todo,简单直接做"。SuppressTodoListMessage(bool)—— 关掉"每轮注入当前清单"的合成消息。默认false(即会注入)。TodoListMessageBuilder(Func<IReadOnlyList<TodoItem>, string>?)—— 自定义那条清单消息的文本格式。不设则用内置格式化。
5.3 TodoItem
公开数据模型,一个待办项就是它:
Id(int)—— 会话内自增的唯一标识,从 1 起。Title(string)—— 标题。Description(string?)—— 可选描述。IsComplete(bool)—— 是否已完成。
字段都带[JsonPropertyName],因为它要随会话状态序列化持久化。
5.4 内部类型
TodoState—— 会话状态本体,持有Items(List<TodoItem>)和NextId(下一个要分配的自增 ID,从 1 起)。TodoItemInput——todos_add工具的入参形状:Title+ 可选Description。TodoCompleteInput——todos_complete工具的入参形状:Id+Reason。
6. 暴露给模型的工具
TodoProvider通过AIFunctionFactory.Create(...)动态生成 5 个工具:
工具名 | 入参 | 返回 | 说明 |
|---|---|---|---|
todos_add | List<TodoItemInput> | 新建的 | 一次可加一个或多个,自动分配自增 ID |
todos_complete | List<TodoCompleteInput> | 实际标记完成的条数 | 按 ID 把未完成项标记为完成 |
todos_remove | List<int>(ID 列表) | 实际删除的条数 | 按 ID 删除待办项 |
todos_get_remaining | 无 | 未完成的 | 查还剩哪些 |
todos_get_all | 无 | 全部 | 查全量(含已完成) |
7. 行为要点
ID 从 1 起自增,由
TodoState.NextId维护,删除不回收 ID。todos_complete的reason不持久化:入参里要Reason,但代码只把对应项的IsComplete置为true,并不存这个理由。它的作用是引导模型"说清为什么算完成了"(对模型推理与日志有益),而非写进状态。这是个容易误以为"理由被存下来了"的反直觉点。每轮注入合成消息:默认每次调用都会把当前清单作为一条 user 消息注入(受
SuppressTodoListMessage/TodoListMessageBuilder控制)。这依赖管线里的消息注入能力把"第三方造的 user 消息"插到正确位置。批量友好:增 / 删 / 完成都支持一次传多个,鼓励模型在一次工具调用里处理一批,减少往返。
8. 与其它模块的关系
HarnessAgent(门面):默认装配
TodoProvider,用HarnessAgentOptions.DisableTodoProvider关闭。Loop:
TodoCompletionLoopEvaluator读TodoProvider的状态,判断"待办是否全部清空"来决定循环是否继续。AgentMode:默认的
plan模式守则里就要求"把任务拆成 todo",两者在"先规划后执行"工作流里配合。Console 脚手架:
/todos命令通过agent.GetService<TodoProvider>()拿到 Provider 后调GetAllTodosAsync,不发起模型调用就打印清单。
9. 扩展与最佳实践
可定制的三个扩展点(都在TodoProviderOptions,构造TodoProvider时传入):
Instructions—— 整段替换默认守则。想改语气、改成中文、或收紧 / 放宽“何时拆 todo”的纪律时用它;不传就用内置守则(已覆盖大多数场景)。SuppressTodoListMessage—— 关掉“每轮注入当前清单”的合成消息。默认不要关:它是模型不掉步的关键。只有当你已用别的方式让模型看到进度、又想省 token 时才考虑关。TodoListMessageBuilder—— 自定义那条清单消息的文本格式(比如换成表格、加优先级列)。
最佳实践:
把 Todos 当“工作便签”,别当持久业务数据。它是会话级、模型驱动的:会话结束即失、
reason不落盘、ID 删了不回收。需要审计或长期留存的清单,另建业务存储,别指望 Todo 状态。做实时待办 UI 就走读方法。用
GetAllTodosAsync/GetRemainingTodosAsync直接读,别为了拿清单去发一次模型调用。注意返回的是内部状态的活引用——UI 只读、别改属性,否则会串改 Provider 状态。配合 Loop 自主续跑时务必设安全阀。
TodoCompletionLoopEvaluator会“待办没清完就再跑一圈”,一定要给LoopAgentOptions.MaxIterations兜底,避免模型迟迟不收敛导致空转。待办的增 / 改 / 删交给模型经工具完成。模块对宿主只开放了读方法(无宿主侧的增 / 删 API),这样清单与模型的认知才不会脱节。
10. 小结
Todo 是 Harness 里最轻、最独立的一块积木:一个AIContextProvider+ 5 个工具 + 一份会话状态,解决"长任务里模型容易忘记自己要干什么"的问题。它的设计取舍很清楚——状态会话隔离、操作批量化、每轮把清单念给模型、并发用每会话锁兜底;同时把GetAllTodosAsync等读方法开放给宿主,方便做实时待办 UI。
下一篇
引入地址