任务清单与自我规划:todo 工具、进度渲染与打转的早期发现
给长任务装一块看板:实现一个让模型自己维护待办清单的工具,把清单实时渲染在终端里,用它约束模型一次只做一件事,并借清单的变化在模型开始打转时第一时间发现。
今日目标
- 能设计一个让模型自己维护任务状态的工具,并说清它为什么能改善长任务表现
- 能在终端里实现清单的增量渲染,不与流式文本互相踩踏
- 能用清单的变化识别停滞与打转,并给出干预策略
昨天给它的是规矩,今天给它的是清单。读完回到页面顶部把三条目标勾掉。
小白版讲解
工作清单贴在工位上:做到哪一条一眼可见
那个新人现在能干活、知道该看哪个文件、也知道团队规矩了。于是你放心地交给他一件大一点的活:修掉这个失败的测试。
四十分钟后你路过他工位问一句「到哪了」,他抬起头开始回忆:我先跑了测试,然后看了 calc.js,然后想改一下,但发现测试里好像还有别的问题,所以又去看了 test 目录,然后……
问题不在于他没干活,而在于「到哪了」这个问题他自己也答不上来。 他脑子里有个计划,但那个计划只存在于他脑子里,而且每看一个文件就悄悄变一点。
解决办法你在任何车间都见过:把清单贴在工位上。 三行字,每行前面一个方框,做完一条划一条。你路过看一眼就知道到哪了,他自己抬头看一眼也知道下一步干什么。
今天要给 mca 装的就是这块看板,而它有一个反直觉的地方:这块看板不是我们替它维护的,是它自己维护的。 我们只提供一个工具(update_todos)和一条硬规则,剩下的由它写、由它勾。
为什么有用?因为模型没有记忆——第一天就说过,它每一轮看到的只有上下文里那些字。它脑子里那个「计划」如果不落成上下文里的一段文字,下一轮就等于不存在;而它下一轮读到自己上一轮写下的清单时,「我做到哪了」就从一个需要回忆的问题变成了一个可以直接读的事实。
这就是今天的核心命题:外化的状态比隐含的计划稳。
为什么交给模型自己维护:把规划变成一次可观测的写操作
一个很自然的反对意见是:既然我们要清单,那我们自己拆解任务、自己维护清单不就行了?
不行,而且理由不是「我们做不到」,是我们拆的清单和它执行的步骤会对不上。你在开始之前拆出三步,它执行到第二步发现根本不是那样——清单成了一份错的地图,而它没有权限改。它只有两种选择:假装照着走(清单是假的),或者不管清单自己干(清单没用)。
反过来,让它自己写有三个好处:
- 计划变成了一次可观测的写操作。 你能看见它打算怎么做,而不是等它做完才知道——它打算做错事的时候尤其值钱。
- 它每一轮都会重新读到自己的计划。 上下文里那三行字是它的锚,比一句「请按计划执行」的提示词硬得多。
- 进度有了一个与具体工具无关的度量。 最后一节会付大利息:判断一个 Agent 有没有在往前走,看的不是它调了几次工具,而是清单动没动。
代价也是真的:每一次更新清单都是一次工具往返。 一张三条的清单,光维护它就可能多花三四轮。所以它有明确的适用边界——最后一节给判据。
状态只留三种:多一种,模型就多一个逃跑的出口
清单的数据模型看着最没技术含量,但每个字段都是取舍。
状态只有三种:待做、进行中、已完成。 你很可能想加第四种——「阻塞」,或者「进行中但卡住了」。别加。
两个理由。第一个便宜:多一种状态,模型每次更新都要多做一次判断,这个判断要花 token 也可能判错。第二个才要紧:「阻塞」给了它一个体面的逃跑出口。 一条做不下去的任务标成阻塞就能名正言顺地绕过去,而清单上看起来一切正常;只有三种状态时,它做不下去就只能说出来——说出来你才能帮它。同理,清单里也没有「放弃」的位置。
第二个决定是两种写法都要有:
- 整表替换(给一整张
items):重新规划时用。「我想了一遍,其实是四步」。 - 单项更新(给
id加status):做完一步时用,也是最常见的动作。
为什么不只留整表替换?模型每改一个状态都要重发整张表——十条的清单每次几百个 token,而且它有不小的概率在重发时把别的条目的文字改错。为什么不只留单项更新?它就没法在中途重新规划了。两种写法覆盖的是两种不同的动作。
第三个决定:清单是易失的,不落盘。 它是这一次任务的工作面,不是事实(那是明天的记忆)也不是过程(那是第七天的会话日志)。任务结束清单就没有意义了,存下来只会让下一次对话看到一份过期的待办。
一次只允许一个进行中:一条硬不变量,胜过一句「请专注」
今天最重要的一条规则,重要性来自一个观察:一个同时「进行中」三件事的 Agent,实际表现是三件都做了一点点、一件都没做完,而清单上看起来非常忙。
你当然可以在系统提示里写「请一次只做一件事」。它有时候听有时候不听,而你不知道这一次它听没听。改成一条会被程序拒绝的规则,效果完全不同:
replace(next: Array<{ id?: string; text: string; status: TodoStatus }>): TodoWrite {
const running = next.filter((item) => item.status === 'in_progress')
if (running.length > 1) {
return {
ok: false,
changed: 0,
// 点名是哪几条,它下一轮才知道该改哪一个。
// 只说「不能有多个」,它只能猜——而猜错就是又一轮往返
note:
`一次只能有一个任务是 in_progress,你给了 ${running.length} 个:` +
`${running.map((item) => item.text).join('、')}。` +
'清单没有被改动。请只把当前真正在做的那一条设为 in_progress,其余保持 pending。',
}
}
// 校验通过才动数据:**整张表要么全改要么全不改**
this.items = next.map(normalize)
this.version += 1
return { ok: true, changed: countChanges(before, this.items), note: describe(this.items) }
}def replace(self, next_items: list[TodoItem]) -> TodoWrite:
running = [item for item in next_items if item.status == "in_progress"]
if len(running) > 1:
names = "、".join(item.text for item in running)
return TodoWrite(
ok=False,
changed=0,
# 点名是哪几条:只说「不能有多个」,模型只能猜,而猜错就是又一轮往返
note=(
f"一次只能有一个任务是 in_progress,你给了 {len(running)} 个:{names}。"
"清单没有被改动。请只把当前真正在做的那一条设为 in_progress,其余保持 pending。"
),
)
# 校验通过才动数据:整张表要么全改要么全不改
changed = sum(1 for item in next_items if self._status_of(item.id) != item.status)
self._items = [replace_id(item, i) for i, item in enumerate(next_items)]
self._version += 1
return TodoWrite(ok=True, changed=changed, note=describe(self._items))三个实现细节,每一个都可以单独出一道面试题:
- 违规时整张表都不改。 半改半不改是最坏的结果:模型以为写进去了,而实际状态是另一个样子。
- 纠正话术要点名是哪几条。 只说「不能有多个」它只能猜,猜错就是又一轮往返。这条和第三天的坏参数回灌、第六天的打转打断走同一条通路——失败的工具结果就是它的下一步说明书。
- 校验放在数据模型里,不放在工具里。 第十三天的计划模式也会写这份清单,它不该再抄一遍校验。
还有一条容易漏的:把一条写成它已经是的状态,允许,但不算改动,版本号不动。因为版本号是本章最后那个停滞检测唯一的进展度量——反复写同一个状态就能让它往上涨,等于给模型留了个作弊入口,而这入口它不需要有恶意就会走进去。
光标只有一条:清单待在固定区域,正文继续往下长
现在是今天的第二个技术难点,那种「不动手不会发现」的难点。
问题的形状很简单:一个终端只有一条光标,现在有两个东西要用它。 流式文本一直往下长,清单要待在不动的地方。你直接 write 一行清单,它会被下一片文本挤上去,半秒之后屏幕上是十几个不同版本的清单。
第一个决定是面板放底部,不是顶部。这条反直觉——看板不该在最上面吗?不该:顶部要算「正文已经滚了多少行」,而正文会自动换行,行数由终端宽度决定,你得知道用户窗口有多宽还得处理他中途拉窗口。底部只需要知道清单自己有几行,这是唯一一个你真的知道的数字。
然后是四个动作:
const SAVE = '\u001b[s' // 保存光标位置
const RESTORE = '\u001b[u' // 回到保存的位置
const CLEAR_BELOW = '\u001b[0J' // 从光标清到屏幕末尾
export class TodoPanel {
/** 画出来:先记住正文停在哪,再换行画清单 */
show(): void {
const rows = renderRows(this.list)
if (rows.length === 0) return
if (!this.tty) return void this.write(`${rows.join('\n')}\n`)
this.write(`${SAVE}\n${rows.join('\n')}\n`)
this.visible = true
}
/** 擦掉:回到正文那半行的位置,把下面的一切清光。正文一个字都没动 */
hide(): void {
if (!this.tty || !this.visible) return
this.write(`${RESTORE}${CLEAR_BELOW}`)
this.visible = false
}
/** 关键的一层:任何一次正文输出都夹在 hide 与 show 之间 */
wrap(out: (chunk: string) => void): (chunk: string) => void {
if (!this.tty) return out // 没有光标就不发光标控制符
return (chunk) => {
this.hide()
out(chunk)
this.show()
}
}
}SAVE, RESTORE, CLEAR_BELOW = "\x1b[s", "\x1b[u", "\x1b[0J"
class TodoPanel:
"""底部面板。contextmanager 正好表达「夹在中间写」这件事。"""
def show(self) -> None:
rows = render_rows(self.list)
if not rows:
return
if not self.tty:
self.write("\n".join(rows) + "\n")
return
self.write(SAVE + "\n" + "\n".join(rows) + "\n")
self.visible = True
def hide(self) -> None:
if not self.tty or not self.visible:
return
self.write(RESTORE + CLEAR_BELOW)
self.visible = False
@contextmanager
def parked(self) -> Iterator[None]:
"""with panel.parked(): print(...) —— 面板让开,写完自己回来"""
self.hide()
try:
yield
finally:
self.show()wrap 那一层是整块面板能成立的全部秘密,而它最漂亮的地方是:渲染层一行代码都没改。 renderTurn 拿到的仍然只是一个「写字符串」的函数,它完全不知道屏幕下方有块面板。这是第二天那层分界今天第二次付红利。
最后是退化路径:非 TTY 没有光标可用,就不发任何光标控制符,退化成「清单每次变化整块打一遍」。这条口径和第二天的打字机一致——发出去让它变成一串乱码,比不做这个功能更糟。
停滞信号:清单不动,就是没有进展
今天最有价值的一段,价值来自一个分工:
| 打转检测(第六天) | 停滞检测(今天) | |
|---|---|---|
| 看什么 | 有没有一字不差地重复调用 | 有没有进展 |
| 度量 | 连续相同的「工具名 + 参数原文」次数 | 清单的版本号多少轮没变 |
| 漏掉什么 | 每次都稍有不同但一直在原地 | 单次的机械重复(前者已经管了) |
| 干预 | 不执行这次调用,回灌一条说明 | 提醒一次;再命中就停下这一轮 |
第六天那个检测漏掉的,正是今天要抓的东西:每一轮都在做不一样的事,但一件都没做完。 读了 A 又读了 B 又读了 C,参数每次都不同,打转检测一次都不会响,而清单从头到尾一个字没变。这是长任务里最常见的失控形态,也最难发现——因为它看起来一直很忙。
所以停滞的度量必须与具体工具无关。本实验用两个信号,都取自现成的东西:
check(rounds: number, revision: number, maxReopen: number): StallSignal | null {
// 版本号变了就是有进展,记下是第几轮变的
if (revision !== this.lastRevision) {
this.lastRevision = revision
this.lastChangeRound = rounds
}
// 重开比不动更严重:它以为做完了,又发现没做完
if (maxReopen >= this.limits.maxReopen) {
return this.signal('reopen', `${NUDGE_PREFIX}有一条任务已经被重新打开 ${maxReopen} 次了…`)
}
// revision 为 0 表示清单一次都没被写过:三步以内的活儿不该被催。
// 少了这条豁免,任何一次不用清单的短对话转到第三轮都会被提醒
const quiet = rounds - this.lastChangeRound
if (quiet >= this.limits.quietRounds && revision > 0) {
return this.signal('quiet', `${NUDGE_PREFIX}任务清单已经 ${quiet} 轮没有变化了…`)
}
return null
}def check(self, rounds: int, revision: int, max_reopen: int) -> StallSignal | None:
if revision != self.last_revision:
self.last_revision, self.last_change_round = revision, rounds
# 重开比不动更严重:它以为做完了,又发现没做完
if max_reopen >= self.limits.max_reopen:
return self._signal("reopen", f"{NUDGE_PREFIX}有一条任务已经被重新打开 {max_reopen} 次了…")
# revision == 0 表示清单一次都没被写过:三步以内的活儿不该被催
quiet = rounds - self.last_change_round
if quiet >= self.limits.quiet_rounds and revision > 0:
return self._signal("quiet", f"{NUDGE_PREFIX}任务清单已经 {quiet} 轮没有变化了…")
return None那条豁免(revision > 0)看着是个小分支,实际是这个功能能不能上线的分界线:少了它,任何一次不用清单的短对话转到第三轮都会被提醒,而这个假警报比不做检测更糟——用户很快就学会无视它了。
干预分两级,先给机会再抬头,和第六天的打转打断同一条思路:第一次命中回灌一条提醒,让它自己更新清单或说清卡在哪;第二次才停下这一轮,走和硬上限一样的出口。
两个实现口径值得记住:
- 提醒用 user 角色回灌,不要用 assistant。 assistant 消息会被它当成自己说过的话,于是它很可能顺着原来那条思路继续;user 消息才是「有人在催」。给模型的纠正,角色选错了效果会反过来。
- 提醒事件复用
error加retryable: true,没有给协议加新类型。 渲染层要知道的只是「循环接下来还会转一圈」,而这正是那一位在第六天定下的语义——这是第五天「审批不进协议」那条裁定的延续。
本实验里 INJECT=loop 时两个机制各就各位:第三次相同调用被打转检测挡下、清单版本号停在 1、0/3 已完成,然后是一条 (系统提醒)任务清单已经 3 轮没有变化了…,再一轮被停下。这些数字在 MOCK=1 下可复现(只取决于剧本与阈值);耗时与真实用量不是。
什么任务不该用清单
最后一节很短,但它决定了这个功能是资产还是负担。
三步以内的活儿不要用清单。 「把这个函数改个名」用清单,你会看到它先花一轮写下「1. 找到所有引用 2. 改名」,再花两轮更新状态——一件三十秒的事变成五轮往返。这不是清单的错,是判据没设对。所以基座提示里那句话是有条件的:三步以上才用。这个判断交给模型,因为只有它知道自己打算分几步;我们能做的是把条件写清楚,而不是写一句「适当时使用清单」。
反过来两种情况清单价值最大:步骤之间有依赖且中间会失败(修测试就是典型:定位、修改、验证,第三步失败要退回第二步——有清单它知道退到哪,没清单它会从头重来);任务长到会跨过一次上下文压缩(第十二天的题目,但结论可以先说:清单是压缩时最该保留的那一类消息)。
最后留一条边界,明天和第十三天各接一半:清单只管「这一件活干到哪了」,不管「该不该干」。 计划要不要先给用户批准是第十三天的事;这个仓库有哪些坑值得记住是明天的事。三样东西各管一段:规矩是别人定的,清单是这一次的,记忆是学到的。
源码导读
动手实验
今天挖了五个练习点,其中三个是「看着无害、实际致命」的陷阱:整表替换不校验进行中的数量、把一条写成它已经是的状态照样涨版本号(等于给模型留了个作弊入口)、面板直接打一遍不管光标。起点代码原样跑是十五项里过五项。
INJECT=loop 那一项必须跨进程验——它在模块加载时就被读进常量,同一个进程里改不了。自检会自己起一个子进程,这是本课从第六天起的固定手法。
- 补齐清单的两条不变量:整表替换与单项更新都不许出现第二个进行中,违规时整张表不动并回灌一条点名的纠正。
- 把单项更新接上工具(
id加status),并让「写成它已经是的状态」不算改动——版本号不动。 - 实现底部面板的
hide/show/wrap:正文输出夹在中间写,非 TTY 退化成整块打一遍。 - 实现停滞检测:清单多少轮不动、同一条被重开几次,两级干预,并且别忘了「清单没被用过就不催」那条豁免。
- 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印15/15 通过。清单状态流转、面板行数、阈值触发、打转时的版本号都是可复现的;耗时与会话 id 不是。
验收看五条勾:自检 15/15 通过;一轮对话里清单从 0/3 已完成 走到 3/3;一次给两个进行中被拒绝且清单一个字没改,下一轮模型自己改对;update_todos 是只读工具、不触发审批门;INJECT=loop 时打转检测与停滞检测先后响,清单版本号停在 1。
面试题
今天三道题,考的是「把规划外化」这件事的收益与代价,不是「怎么存一个数组」:
- 让模型自己维护任务清单,比在提示词里写计划好在哪?代价是什么?
- 终端里同时有流式文本和固定的进度区域,你怎么管光标与刷新?
- 怎么从运行数据里发现 Agent 已经在打转?发现之后怎么干预?
完整的中英题干、分析过程与答题要点见本课面试题库的第十天。第三题最容易答成「检测重复调用」——那只是一半,能说出「每次都稍有不同但一直在原地」这种形态怎么抓的人不多。
检查清单与明日预告
- 能说出「外化的状态比隐含的计划稳」的意思,以及清单为什么该由模型自己维护
- 知道状态为什么只留三种,以及第四种状态会给模型留下什么出口
- 能说清整表替换与单项更新各自对应什么动作,为什么两种都要有
- 能解释「一条硬不变量胜过一句提示词」,以及违规时为什么整张表都不改
- 知道面板为什么放底部,以及
wrap那一层为什么能让渲染层一行不改 - 能说出打转检测与停滞检测的分工,以及「清单没被用过就不催」那条豁免为什么关键
- 能说出两种不该用清单的情况,以及清单价值最大的两种情况
明天是 D11《跨会话记忆:显式记忆、自动记忆与检索注入的三个判据》。今天这块清单是易失的——任务结束它就该消失。明天正好相反:一件值得跨会话记住的事实,怎么写下来、怎么被找回来、以及什么东西绝不该被记住。先做清单再做记忆,是因为清单已经把「什么该留下」这个问题问清楚了:清单是这一次的工作面,记忆是下一次还用得上的东西——分不清这两者,记忆目录会在两周内变成垃圾场。
面试题库
让模型自己维护任务清单,比在提示词里写计划好在哪?代价是什么?What does letting the model maintain its own task list buy you over writing the plan in the prompt, and what does it cost?
国内高频海外高频基础#self-planning#tool-design分析过程 · 先想清楚再作答
- 这题在考「你有没有想过这个功能为什么有效」。答「让它更有条理」是空话,会被追问到底。区分度在于你能不能说出一个机制层面的理由,再主动交出代价。
- 怎么拆:先回到一条最基本的事实——**模型没有记忆,它每一轮看到的只有上下文里的那些字。** 所以「计划」如果只存在于它这一轮的推理里,下一轮就等于不存在。清单的作用是把计划**外化成上下文里的一段文字**,于是「我做到哪了」从一个需要回忆的问题变成了一个可以直接读的事实。一句话概括:**外化的状态比隐含的计划稳。**
- 为什么不是我们替它拆?因为我们拆的清单和它执行的步骤会对不上。你在开始之前拆三步,它执行到第二步发现根本不是那样——清单成了一份错的地图,而它没有权限改;它只能假装照着走(清单是假的)或者不管清单自己干(清单没用)。让它自己写还多两个好处:**计划变成一次可观测的写操作**(你能在它做错之前看见它打算怎么做),以及**进度有了一个与具体工具无关的度量**(第三题会用到)。
- 代价必须主动说,这是这题的分水岭:**每一次更新清单都是一次工具往返。** 一张三条的清单光维护它就可能多花三四轮,而每一轮都要把整个消息数组重发一次。所以这个功能有明确的适用边界——三步以内的活儿不要用清单,加了就是把一件三十秒的事变成五轮往返。反过来两种情况价值最大:步骤之间有依赖且中间会失败(失败要退回上一步,有清单才知道退到哪),以及任务长到会跨过一次上下文压缩(清单用几十个 token 保住了「整件事到哪了」)。
- 还要讲一个具体的设计取舍来证明你真做过:**状态只留三种(待做、进行中、已完成)。** 加第四种「阻塞」看着更完备,实际是给模型一个体面的逃跑出口——一条做不下去的任务标成阻塞就能名正言顺地绕过去,而清单上看起来一切正常。只有三种状态时,它做不下去就只能说出来,而说出来你才能帮它。
- 可预期的追问:清单要不要落盘?不要。它是这一次任务的工作面,不是事实也不是过程;任务结束就没有意义了,存下来只会让下一次对话看到一份过期的待办,然后先花一轮判断这份待办还算不算数。
How to reason about it · think before answering
- This tests whether you have thought about why the feature works. Answering it makes the model more organized is empty and invites follow-ups until you break. The signal is a mechanism-level reason plus volunteering the cost.
- How to break it down: start from the basic fact that the model has no memory — every round it sees only the text in the context. So a plan that exists only inside one round's reasoning does not exist in the next. The list externalizes the plan into context text, turning where am I from something to recall into something to read. In one line: externalized state is more stable than an implied plan.
- Why not decompose it ourselves? Because our list and its actual steps diverge. You break the work into three steps up front, it reaches step two and finds reality differs — the list is now a wrong map it has no permission to fix, so it either pretends to follow it (the list is fake) or ignores it (the list is useless). Letting the model write it adds two more benefits: the plan becomes an observable write, so you see the intent before the mistake; and progress gains a tool-agnostic metric, which the third question builds on.
- You must volunteer the cost, and that is the dividing line: every list update is a tool round trip. Maintaining a three-item list can cost three or four extra rounds, and each round resends the whole message array. So the feature has a clear boundary — do not use a list for work under three steps, or a thirty-second task becomes five round trips. Conversely, it pays off most when steps have dependencies and can fail mid-way (you need to know which step to fall back to) and when the task is long enough to survive a context compaction, where the list preserves where am I for a few dozen tokens.
- Cite a concrete design tradeoff to show you built it: keep only three states — pending, in progress, done. Adding blocked looks more complete but hands the model a respectable escape hatch: a task it cannot finish gets marked blocked and legitimately skipped while the list looks fine. With only three states it has to say it is stuck, and only then can you help.
- Likely follow-up: should the list be persisted? No. It is this task's working surface, neither a fact nor a record of process; once the task ends it is meaningless, and persisting it just shows the next conversation a stale to-do list it must first spend a round evaluating.
答题要点
- 机制理由:模型没有记忆,清单把计划外化成上下文里可读的一段文字
- 不该我们替它拆:我们拆的步骤会与它的执行对不上,清单会变成一份它无权修改的错地图
- 额外收益:规划变成可观测的写操作;进度有了与具体工具无关的度量
- 代价:每次更新都是一次工具往返,所以三步以内不用清单;依赖多、会失败、会跨压缩的任务价值最大
- 状态只留三种:第四种「阻塞」是给模型的逃跑出口;清单易失不落盘
Key points
- The mechanism: the model has no memory, so the list externalizes the plan into readable context text
- Do not decompose for it: our steps diverge from its execution, leaving a wrong map it cannot edit
- Extra upside: planning becomes an observable write, and progress gains a tool-agnostic metric
- Cost: every update is a round trip, so skip lists under three steps; they pay off on dependent, failure-prone, long tasks
- Only three states: a fourth blocked state is an escape hatch; the list is ephemeral and not persisted
终端里同时有流式文本和固定的进度区域,你怎么管光标与刷新?With streaming text and a fixed progress area in the same terminal, how do you manage the cursor and repaints?
国内高频海外高频进阶#terminal-rendering#cursor-control分析过程 · 先想清楚再作答
- 这题很难靠背答案过,因为它的坑只有动手写过才知道。区分度有三层:能不能说清冲突的本质、能不能给出一个可实现的方案、能不能主动讲退化路径。
- 怎么拆:先说冲突的本质——**一个终端只有一条光标,而现在有两个东西要用它。** 流式文本一直在往下长,进度区域要待在一个不动的地方。直接把进度打出去,下一片文本就会把它挤上去,几秒之后屏幕上是十几个不同版本的进度。
- 第一个决定是**面板放底部,不是顶部**。这一条反直觉但很硬:顶部要算「正文已经滚了多少行」,而正文会自动换行,行数由终端宽度决定——你得知道用户窗口有多宽,还得处理他中途拉窗口。**底部只需要知道面板自己有几行,这是唯一一个你真的知道的数字。**
- 方案就是四个动作:画出来(保存光标位置 → 换行 → 画面板,光标停在面板末尾);擦掉(恢复到保存的位置 → 清掉从这里到屏幕末尾的一切,面板没了而正文那半行还在);写正文(夹在擦掉与画出来之间);内容变了就擦掉再画一次。用 DEC 的保存/恢复光标而不是自己数行,是因为它只有一个槽位但你也只需要一个,而且它天然处理了「正文那半行停在第几列」这个你算不出来的问题。
- 然后是这题的加分项:**把这一层做成一个包装函数,而不是散在渲染代码里。** 拿一个「写字符串」的函数进来,还一个夹了 hide/show 的函数出去。做对之后渲染层一行都不用改——它完全不知道屏幕下方有块面板。这就是分层的价值,而不是「代码更整洁」这种空话。
- 退化路径必须主动说:**非 TTY(管道、CI、别的 Agent 的 shell)没有光标可用,就一个光标控制符都不发**,退化成「内容变了就整块打一遍」。判据是 `isTTY`。把控制符发出去让它变成一串乱码,比不做这个功能更糟——而这正是「验收判据是肉眼看到现象、而不是 exit code」的原因。
- 可预期的追问:为什么不用 alt-screen(整屏接管)?因为终端会话的历史就没了,用户翻不回上一个任务的输出;一个 CLI 工具占掉整屏,代价远大于收益。真要做全屏 TUI 才考虑它。
How to reason about it · think before answering
- This one is hard to fake, because its pitfalls only show up once you have written it. There are three levels of signal: naming the real conflict, giving an implementable scheme, and volunteering the degradation path.
- How to break it down: the conflict is that a terminal has one cursor and two things now want it. Streaming text keeps growing downward while the progress area must stay put. Print the progress directly and the next chunk pushes it up, so within seconds the screen holds a dozen stale copies.
- The first decision is to put the panel at the bottom, not the top. It is counterintuitive but firm: the top requires knowing how many lines the body has scrolled, and the body soft-wraps, so its line count depends on terminal width — you would need the window width and would have to handle the user resizing mid-run. The bottom only requires knowing how many lines the panel itself has, which is the one number you actually know.
- The scheme is four actions: draw (save the cursor, newline, paint the panel, leaving the cursor at its end); erase (restore to the saved position, then clear from there to the end of the screen — the panel is gone and the half-written body line survives); write body text (sandwiched between erase and draw); and repaint on change by erasing and drawing again. Use the DEC save/restore cursor rather than counting lines: there is only one slot but you only need one, and it naturally handles which column the half-written line stopped at, a number you cannot compute.
- Then the bonus: make this a wrapper function rather than scattering it through render code. Take a write-string function in, hand a hide/show-sandwiched one back. Done right, the render layer needs no changes at all — it never learns there is a panel below. That is what layering buys, as opposed to vague talk about cleaner code.
- Volunteer the degradation path: a non-TTY destination (a pipe, CI, another agent's shell) has no usable cursor, so emit no cursor control sequences at all and degrade to reprinting the block whenever it changes. The test is the isTTY flag. Emitting control sequences into a pipe turns them into garbage, which is worse than not having the feature — and it is exactly why acceptance must be a human looking at the screen rather than an exit code.
- Likely follow-up: why not use the alternate screen buffer? Because the session scrollback is then gone and the user cannot scroll back to the previous task's output. A CLI taking over the whole screen costs far more than it gains; consider it only for a genuine full-screen TUI.
答题要点
- 冲突本质:一个终端一条光标,流式文本往下长而面板要不动
- 面板放底部:顶部要算正文滚了多少行,而软换行让行数取决于窗口宽度;底部只需知道面板自己几行
- 四个动作:保存光标画面板、恢复光标清到屏幕末尾、正文夹在中间写、变化时擦掉重画
- 把 hide/show 做成一个包装函数,渲染层一行都不用改,也不知道面板存在
- 非 TTY 一个控制符都不发,退化成整块重打;判据是 isTTY,验收靠肉眼看现象
Key points
- The conflict: one cursor, with streaming text growing downward and a panel that must stay put
- Put the panel at the bottom: the top needs the body's scrolled line count, which soft-wrapping makes width-dependent
- Four actions: save cursor and paint, restore cursor and clear below, write body in between, repaint on change
- Make hide/show a wrapper function so the render layer needs no changes and never learns the panel exists
- On a non-TTY emit no control sequences and reprint the block; the test is isTTY, and acceptance is visual
怎么从运行数据里发现 Agent 已经在打转?发现之后怎么干预?How do you detect from runtime data that an agent is spinning in place, and what do you do once you detect it?
国内高频海外高频深入#stall-detection#agent-observability分析过程 · 先想清楚再作答
- 这题最容易只答一半:「检测重复调用」。那确实是一种打转,但它是最容易抓也最不常见的那一种。区分度在于你能不能说出另一种形态,以及为什么第一种检测抓不到它。
- 怎么拆:把打转分成两种形态。**机械重复**——一字不差地反复调同一个工具同一个参数;**原地兜圈**——每一轮都在做不一样的事,但一件都没做完。第一种用「工具名 + 参数原文」当键,连续命中到阈值就打断,实现很朴素;关键是三条口径:比参数原文不比语义(只差一个空格就算在尝试新东西),只看连续(中间插过别的调用就重新计数,因为「读了改了再读一次确认」是正常节奏),以及打断的方式是回灌而不是抬头。
- 第二种才是长任务里最常见、也最难发现的:读了 A 又读了 B 又读了 C,参数每次都不同,重复检测一次都不会响,而它其实一直在原地。它最难发现的原因是**它看起来一直很忙**。
- 所以你需要一个**与具体工具无关的进展度量**,而任务清单正好提供了一个:清单的版本号多少轮没变。再加一个更严重的信号:同一条任务被从「已完成」重新打开了几次——它的意思是「它以为做完了,又发现没做完」,连续两次基本可以断定在兜圈子。两个信号加上轮数(本来就有的资源记账)就够了,不需要新的埋点。
- 干预分两级,**先给机会再抬头**:第一次命中回灌一条提醒(清单多少轮没动、请更新清单或说清卡在哪),这一轮照常继续;第二次命中才停下这一轮,走和硬上限完全一样的出口。两个实现细节值得讲:提醒要用 **user 角色**回灌而不是 assistant——assistant 消息会被它当成自己说过的话,于是它顺着原思路继续;user 消息才是「有人在催」。以及提醒事件复用现有的「可重试错误」那一位,**不给事件协议加新类型**,因为渲染层要知道的只是「循环接下来还会转一圈」。
- 最后是这题真正的深水区——**假警报的成本**。停滞检测必须有一条豁免:清单一次都没被写过时不判停滞。少了它,任何一次不用清单的短对话转到第三轮都会被提醒,而用户很快就学会无视所有提醒了。**一个会误报的守卫等于没有守卫**,这条判断比阈值调多少更重要。
- 可预期的追问:那三条硬上限(轮数、时长、token)算不算打转检测?不算,它们是资源兜底:**上限看的是花了多少,停滞看的是有没有进展。** 一个五轮就把 token 烧光的任务和一个转了八轮啥也没干的任务,是两种不同的失控,各需要一条闸。
How to reason about it · think before answering
- The easy half-answer is detect repeated calls. That is one kind of spinning, but the easiest to catch and the less common one. The signal is naming the other shape and explaining why the first detector misses it.
- How to break it down: two shapes. Mechanical repetition — calling the same tool with byte-identical arguments over and over. And circling in place — doing something different every round while finishing nothing. For the first, key on tool name plus raw argument text and interrupt once consecutive hits reach a threshold. Three rulings matter: compare raw arguments, not meaning (a one-space difference means it is at least trying something new); count only consecutive hits (an intervening different call resets, because read, edit, read again to confirm is a healthy rhythm); and interrupt by feeding a result back, not by escalating to the user.
- The second shape is the common and hard one in long tasks: it read A, then B, then C, arguments differ every time, the repetition detector never fires, and it is still exactly where it started. It is hard to spot because it looks busy the whole time.
- So you need a tool-agnostic progress metric, and a task list supplies one: how many rounds the list's revision number has not changed. Add a more serious signal — how many times a single item has been reopened from done, which means it thought it was finished and then found it was not; twice in a row is close to proof of circling. Those two signals plus the round counter you already keep for resource limits are enough; no new instrumentation required.
- Intervene in two stages, giving it a chance before escalating: on the first hit, feed back a reminder (the list has not moved for N rounds, update it or state where you are stuck) and let the round continue; only on the second hit stop the turn, through the same exit as the hard limits. Two implementation details are worth mentioning: feed the reminder as a user-role message, not assistant — an assistant message reads to the model as something it said itself, so it continues the same line of thought, while a user message reads as someone prodding it. And reuse the existing retryable-error signal rather than adding a new event type, since all the render layer needs to know is that the loop will go around again.
- The real deep end is the cost of false positives. Stall detection needs an exemption: do not judge staleness when the list has never been written at all. Without it, any short conversation that legitimately skips the list gets nagged by round three, and users quickly learn to ignore every warning. A guard that misfires is no guard, and that judgment matters more than the exact threshold.
- Likely follow-up: do the three hard limits — rounds, wall time, tokens — count as spin detection? No, they are resource backstops. Limits watch how much you spent; stall detection watches whether you moved. A task that burns its token budget in five rounds and a task that spins for eight rounds doing nothing are different failures and each needs its own gate.
答题要点
- 打转有两种形态:机械重复(同工具同参数)与原地兜圈(每轮都不同但没进展)
- 重复检测的三条口径:比参数原文、只看连续、打断方式是回灌而不是抬头
- 兜圈要靠与工具无关的进展度量:清单版本号多少轮没变,加上同一条被重开几次
- 干预两级:先回灌提醒(用 user 角色,不用 assistant),再命中才停下这一轮
- 必须有豁免:清单没被用过就不判停滞——会误报的守卫等于没有守卫
Key points
- Two shapes: mechanical repetition (same tool, same arguments) and circling (different each round, no progress)
- Three rulings for repetition: compare raw arguments, count only consecutive hits, interrupt by feeding back
- Circling needs a tool-agnostic progress metric: rounds since the list's revision changed, plus reopen counts
- Two-stage intervention: feed back a reminder first (as a user-role message, not assistant), stop only on the second hit
- An exemption is mandatory: never judge staleness when the list was never used — a guard that misfires is no guard