找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4102

积分

0

好友

540

主题
发表于 昨天 23:36 | 查看: 3| 回复: 0

给 Agent 一个 10 步的重构任务,它干得挺好——改了前两个文件,第三步跑测试,结果两个 case 挂了。从这一刻起,它的注意力全被测试报错吸走,最初“把所有 Python 文件改成 snake_case 命名”这件事悄悄消失了。等它走完一轮,你会发现测试修好了,但有将近一半的文件根本没动。

这不是模型本身的问题,而是上下文窗口的物理性质决定的。

上下文越长,系统提示越没用

Agent 每调用一次工具,工具的返回值都会被追加进 messages 里。任务进行到第 5 步时,messages 中早已塞满 bash 输出、文件内容、编辑记录。而系统提示(SYSTEM)从第一轮起就固定在最靠前的位置,随着对话推进,在模型视野里越来越“远”了。

模型生成下一步动作时,对上下文里越靠近当前位置的 token 赋予的权重越高——这是 Transformer 注意力机制本身的特性,不是 bug,是设计如此。所以你在第 1 步设定的任务计划,到第 5 步时,影响力已经大幅衰减。对话越长,问题越严重:一个需要 15 步才能完成的重构,做完前三步后,第 4~15 步的计划几乎被稀释殆尽。Agent 进入“即兴发挥”状态,哪里有输出就扑向哪里。

这就是为什么上下文越长,系统提示越显得“没用”。

解法不是让模型“更聪明”,而是把计划钉在消息历史里

问题的根源很清楚:计划消失了,是因为它写在系统提示里,而系统提示随着对话深入逐渐失效。解法随之而来:让计划本身成为 messages 里的内容,并且每一步执行后都能更新它,使最新状态始终出现在上下文靠近当前位置的地方。

这就是 todo_write 工具的核心逻辑。

它不执行任何实际操作,不读文件,也不跑命令,只做一件事:接收一个带状态的任务列表,存入内存,同时将当前状态作为工具结果返回给模型。每次调用 todo_write,模型就像在 messages 中追加了一条新的进度快照——永远新鲜、永远可见。

TodoWrite 系统架构:循环体、工具分发与Nag催更

实现细节

任务列表的数据结构

每个任务都是一个字典,包含两个字段:

{
    "content": "Add type hints to all functions",
    "status": "in_progress"   # pending | in_progress | completed
}

三种状态对应任务的生命周期:待开始 → 进行中 → 已完成。

全局维护一个列表:

CURRENT_TODOS: list[dict] = []

注意,这是进程内存在,退出就清空。设计如此:同一个对话内共享状态,不同会话各自独立,不需要持久化。

run_todo_write 的实现

def run_todo_write(todos: list) -> str:
    global CURRENT_TODOS
    todos, error = _normalize_todos(todos)
    if error:
        return error
    CURRENT_TODOS = todos

    lines = ["\n\033[33m## Current Tasks\033[0m"]
    for t in CURRENT_TODOS:
        icon = {
            "pending":     " ",
            "in_progress": "\033[36m▸\033[0m",
            "completed":   "\033[32m✓\033[0m"
        }[t["status"]]
        lines.append(f"  [{icon}] {t['content']}")
    print("\n".join(lines))
    return f"Updated {len(CURRENT_TODOS)} tasks"

有个细节值得留意:函数里的 print 是给终端看的,让人能知道 Agent 在规划什么;但 return 的字符串才是真正重要的——它作为工具结果追加入 messages,模型下一轮生成时就会读到它。

_normalize_todos 负责输入校验,因为模型有时会把参数序列化成 JSON 字符串而不是直接传列表,需要兼容两种格式:

def _normalize_todos(todos):
    if isinstance(todos, str):
        try:
            todos = json.loads(todos)
        except json.JSONDecodeError:
            try:
                todos = ast.literal_eval(todos)
            except (SyntaxError, ValueError):
                return None, "Error: todos must be a list or JSON array string"
    if not isinstance(todos, list):
        return None, "Error: todos must be a list"
    for i, t in enumerate(todos):
        if not isinstance(t, dict):
            return None, f"Error: todos[{i}] must be an object"
        if "content" not in t or "status" not in t:
            return None, f"Error: todos[{i}] missing 'content' or 'status'"
        if t["status"] not in ("pending", "in_progress", "completed"):
            return None, f"Error: todos[{i}] has invalid status '{t['status']}'"
    return todos, None

接入 dispatch 系统

上一版本已经有一套 TOOL_HANDLERS 分发机制:工具调用根据 tool_call.function.name 找到对应处理函数。新增 todo_write 只需两步。

第一步,把工具定义加入 TOOLS 列表:

{
    "type": "function",
    "function": {
        "name": "todo_write",
        "description": "Create and manage a task list for your current coding session.",
        "parameters": {
            "type": "object",
            "properties": {
                "todos": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "content": {"type": "string"},
                            "status": {
                                "type": "string",
                                "enum": ["pending", "in_progress", "completed"]
                            }
                        },
                        "required": ["content", "status"]
                    }
                }
            }
        }
    }
}

第二步,加进 dispatch map:

TOOL_HANDLERS["todo_write"] = run_todo_write

分发逻辑不用改。todo_write 进来后,TOOL_HANDLERS.get("todo_write") 找到函数,解析参数,调用,返回结果——与 bashread_file 走完全一样的路径。

系统提示加一句话

SYSTEM = (
    f"You are a coding agent at {WORKDIR}. "
    "Before starting any multi-step task, use todo_write to plan your steps. "
    "Update status as you go."
)

这句话为模型建立了默认行为模式:收到任务 → 先列计划 → 再动手。没有这句,模型不会主动使用 todo_write,因为它根本不知道这个工具的预期使用时机。

Nag Reminder:连续 3 轮没更新就提醒一次

还有个很实际的问题:模型执行途中容易忘了更新 TODO 状态。任务开始时列了计划,可做了三四个工具调用后,停下来更新 todo_write 的意识就被稀释掉了。

解法是在 Agent 循环里加一个计数器:

rounds_since_todo = 0
def agent_loop(messages: list):
    global rounds_since_todo
    while True:
        # 超过 3 轮没调 todo_write,注入提醒
        if rounds_since_todo >= 3 and messages:
            messages.append({
                "role": "user",
                "content": "<reminder>Update your todos.</reminder>"
            })
            rounds_since_todo = 0

        response = client.chat.completions.create(...)
        messages.append(response.choices[0].message)

        if not response.choices[0].message.tool_calls:
            # 模型停止工具调用,触发 Stop 钩子后退出
            ...
            return

        rounds_since_todo += 1  # 每轮工具调用递增
        for tool_call in response.choices[0].message.tool_calls:
            ...
            # 调用 todo_write 时重置计数器
            if tool_call.function.name == "todo_write":
                rounds_since_todo = 0
            ...

rounds_since_todo 是全局的,每轮工具调用递增,todo_write 被调用时归零。连续三轮没调,就往 messages 里塞一条 role: user 的提醒消息。

为什么用 role: user 而不是 role: system?很多模型对 user 消息的响应优先级高于 system,注入到对话流里比修改系统提示效果更稳定。

Agent 执行的典型流程

Agent 执行典型流程:利用 todo_write 追踪多步任务状态

一个正常工作的流程是这样的:

  1. 用户发送任务:“重构这个文件,加类型注解、docstring,再补一个 main guard”
  2. Agent 第一次工具调用:todo_write,列出 3 个 pending 的步骤
  3. 调用 read_file 读文件内容
  4. 调用 edit_file 加类型注解,同时更新 todo_write,第一步改成 in_progress
  5. 完成后再次调用 todo_write,第一步改成 completed,第二步改成 in_progress
  6. 继续执行,直到所有步骤变成 completed
  7. 模型停止工具调用,输出总结

每次调用 todo_write,当前的完整任务列表就会出现在 messages 中。模型在后续每一轮生成时,都能看到“现在在做哪步、还有哪些没做”——这才是这个工具真正有效的原因。

这不是在增加执行能力,是在增加规划能力

todo_write 没让 Agent 能做更多事情。它在不改变 Agent 执行能力的前提下,增加了结构化规划进度追踪能力。

两者的区别很重要。执行能力是“Agent 能调用什么工具”,规划能力是“Agent 知道自己在做什么、下一步该做什么”。上下文稀释导致的问题,根源在规划能力的丧失,不在执行能力。所以加一个不执行任何操作的工具,专门用来保持规划状态,就能把问题解决。

这个设计思路在更复杂的系统里会一再出现:不是给 Agent 加更强的工具,而是给 Agent 加更好的状态管理

相比上一版本,改动极小

上一版本引入了 Hooks 系统:四个事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop)、一个注册表、一个触发函数。这套机制这一版一行没动。

这一版的变更点只有四个:

  • 新增 run_todo_write()_normalize_todos() 两个函数
  • TOOLS 列表里加一条 todo_write 的定义
  • TOOL_HANDLERS 里加一行 dispatch 映射
  • agent_loop 里加一个 rounds_since_todo 计数器和 reminder 注入

SYSTEM 提示改了一句话。其他全部不变。

加一个工具,改一行提示,加一个计数器——Agent 就从“做着做着就偏”变成了“先列计划再执行,做完打钩,忘了提醒你”。

往深处看:任务系统的两个版本

如果去翻真实的 Claude Code 源码,会发现任务系统有两套实现并存。

V1(也就是这一版实现的原型):一个工具,数据在进程内存的 AppState 里维护,退出清空。交互简单,适合单次会话内的任务追踪。

V2(后面会讲到):四个独立工具(Create/Get/Update/List)、文件持久化(存在 Claude 配置目录的 tasks/{taskListId}/{taskId}.json)、blockedBy 字段支持依赖图、proper-lockfile 做并发安全。

两套系统由 isTodoV2Enabled() 控制切换:交互式会话默认启用 V2,非交互式会话(通过 SDK 调用)默认使用 V1。设置环境变量 CLAUDE_CODE_ENABLE_TASKS 可以在非交互式下强制开启 V2。

注意源码里有一个容易误读的注释:“Force-enable tasks in non-interactive mode”,描述的是这个环境变量路径的用途,而不是 isTodoV2Enabled() 默认分支的返回值语义。读源码的时候要区分清楚。

V1 和 V2 还有一个细节差异:V1 没有 activeForm 字段,V2 有,用来给 UI 的 spinner 展示“正在做什么”。终端版本不需要这个字段,因为 print 直接输出就够了。

跑起来看什么

运行起来之后,重点观察几件事:

  • 收到任务后,第一次工具调用是不是 todo_write?如果模型直接开始调 bashread_file,说明系统提示的引导没生效,需要调整提示措辞。
  • TODO 列了几步?和任务的实际复杂度匹配吗?过少说明模型对任务理解不够细;过多说明模型在过度拆解,增加了不必要的上下文占用。
  • pending → in_progress → completed 的状态流转有没有发生?如果所有任务直接跳过 in_progress,说明模型在批量完成后才更新状态,中间过程没有追踪,遇到中断就会丢失进度。
  • reminder 在什么时候触发?如果一个简单任务就触发了 reminder,说明任务分解可能太细,每步之间频繁切换工具,导致 todo 更新间隔拉长。

这几个观察点加在一起,基本就能判断 Agent 的规划行为是否符合预期。

下一步要面对的问题是:任务大到一定程度,再完善的 TODO 列表也扛不住。如果把“重构整个认证模块”拆成 30 个步骤,放在同一个对话里执行,上下文本身就会成为瓶颈。

解法是把大任务拆成子任务,每个子任务开一个独立的 Agent,各自有干净的上下文,互不干扰。下一版会讲这个。




上一篇:Agent Loop核心拆解:一个while循环如何驱动大模型与工具交互
下一篇:Hook机制如何给Agent循环解耦:从零到复刻Claude Code的架构实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-6-27 02:21 , Processed in 1.798020 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表