mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-22 05:13:48 +08:00
feat: refresh goal loop lesson
This commit is contained in:
@@ -1,152 +1,231 @@
|
||||
# s21: Goal Loop — 什么时候停,目标说了算,不是模型说了算
|
||||
# s21: Goal Loop:模型提出停止,独立判断器决定是否继续
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s19 → s20 → `s21`
|
||||
|
||||
> *"一轮能不能结束,看目标条件满不满足,不是模型说停就停"* — `/goal` 在主循环每轮收尾的地方加一道闸门:每轮结束后,一个独立的判断器看可信证据够不够,不够就把模型推回去再来一轮。
|
||||
> *“模型不再调用工具,只代表这一轮想停;目标是否完成,再交给一个独立判断器。”*
|
||||
>
|
||||
> **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。
|
||||
> **Harness 层:持续执行。** 在每轮结束处检查完成条件,没有完成就继续下一轮。
|
||||
|
||||
---
|
||||
|
||||
从 s01 到 s20,一轮对话怎么结束?模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。
|
||||
|
||||
但有些目标你得盯着它做到底:"把测试跑过"、"部署成功了再说"。这时候经常出两种问题:模型做了一半觉得差不多了,自己就停了;更过分的是,它嘴上说一句 `tests passed` 就想收工。你要的其实很简单:这一轮能不能结束,不能模型自己说了算,得有个明确的条件,对着实打实的证据来判断。
|
||||
|
||||
这条线其实从第一课就埋着了。s01 说过,退出循环本来是模型的一个决定;s04 的 Stop hook 第一次给了程序否决权。这一课把那个否决权做成完整的闭环:条件、证据、预算,三样缺一不可。
|
||||
|
||||
## /goal:每轮收尾加一道闸门
|
||||
|
||||
输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条"继续干"的提示进下一轮;够了,就清除目标,标记完成。
|
||||
|
||||

|
||||
|
||||
和 s01 的循环比,只多了一道判断,模型想停的时候先过目标这关:
|
||||
从 s01 开始,Agent Loop 的退出条件一直很简单:模型不再调用工具,程序就返回。
|
||||
|
||||
```python
|
||||
# s01:模型说停就停
|
||||
if not has_tool_use(response):
|
||||
return
|
||||
# s21:想停?先过目标闸门
|
||||
if not has_tool_use(response):
|
||||
verdict = goal.evaluate_after_turn()
|
||||
if verdict == "continuing":
|
||||
continue # 没达成 -> 推回去再来一轮
|
||||
return # 达成/超预算/没目标 -> 真停
|
||||
这对普通对话足够,但对“修到测试全部通过”“完成所有验收项”这样的任务还不够。模型可能认为已经做完,也可能只完成了一部分。没有新的 `tool_use`,只能说明当前轮次结束了,不能直接证明整个目标已经达成。
|
||||
|
||||
`/goal` 在真正返回之前,再加一次独立判断。
|
||||
|
||||
## /goal 是一个会话级 Stop hook
|
||||
|
||||
输入:
|
||||
|
||||
```text
|
||||
/goal pytest tests/auth 退出码为 0,并且 lint 没有错误
|
||||
```
|
||||
|
||||
这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。
|
||||
程序保存完成条件,并立即把这段条件作为本轮任务交给主模型。用户不需要再输入一条“开始执行”。
|
||||
|
||||
## 设目标:证据从命令之后开始算
|
||||
|
||||
`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器和 `start_index`。其中,`start_index` 表示证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。
|
||||
当主模型不再调用工具时,主循环不会立刻 `return`,而是先运行 Goal Stop hook:
|
||||
|
||||
```python
|
||||
def set_goal(self, objective, max_turns=20):
|
||||
self.active = {
|
||||
"objective": objective, "status": "active",
|
||||
"start_index": len(self.transcript), # 证据窗口从这里开始;命令本身在窗口外
|
||||
"max_turns": max_turns, "checks": 0, "continuation_turns": 0,
|
||||
}
|
||||
if tool_results:
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
continue
|
||||
|
||||
decision = await self.goal.evaluate_after_turn(self.messages)
|
||||
if decision.action == "block":
|
||||
self.messages.append({
|
||||
"role": "user",
|
||||
"content": decision.reason,
|
||||
})
|
||||
continue
|
||||
|
||||
return SessionResult(text=text, status=decision.action)
|
||||
```
|
||||
|
||||
## 判断器:只信实打实的证据
|
||||
没有活跃目标时,这个 hook 直接放行,循环仍然和 s01 一样。
|
||||
|
||||
这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把"嘴上说完成了但不算数"的内容全挡在外面:
|
||||
## 判断器和干活的模型分开
|
||||
|
||||
主模型负责修改代码、运行命令和解决问题。Goal 判断器是另一次独立的模型调用,只负责判断完成条件。
|
||||
|
||||
判断器由 `GoalController` 持有,是 Goal Gate 的内部依赖,不是主循环之外的另一条退出路径。
|
||||
|
||||
本课没有单独的 `CommandQueue`:判断未通过时,controller 把理由直接追加到同一份 `messages[]`,然后进入下一轮。更大的宿主可以用共享队列把用户输入、后台结果和继续命令送回会话,但那条队列服务的是整个宿主,只负责传递,不归 Goal Gate 所有。把它画进 Gate,会把"谁做决定"和"决定从哪条路送回来"混成一件事。
|
||||
|
||||
判断器会看到:
|
||||
|
||||
- 当前 Goal 的完成条件;
|
||||
- 到目前为止的对话记录;
|
||||
- 主模型运行工具后写回来的结果。
|
||||
|
||||
判断器没有工具,不能自己读取文件,也不能重新运行测试。它只能根据对话中已经出现的内容做判断:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"reason": "对话中还没有出现 pytest 的退出码",
|
||||
"impossible": false
|
||||
}
|
||||
```
|
||||
|
||||
`ok=true` 表示条件已经满足;`ok=false` 表示还要继续;如果目标已经无法完成,则返回 `impossible=true`。
|
||||
|
||||
## 对话记录就是判断依据
|
||||
|
||||
判断器读取当前对话。工具结果、主模型的说明和后台任务通知都会作为消息进入其中,最终判断取决于这些消息实际写了什么。
|
||||
|
||||
这并不表示模型说一句“测试通过了”就一定会被接受。判断器的提示明确要求根据对话中的具体结果判断,不能把没有结果支撑的宣称当成完成。
|
||||
|
||||
但它终究只是一个只读对话的模型,可靠性取决于对话里有没有把关键结果说清楚。因此主模型的 system prompt 会要求:
|
||||
|
||||
> 运行验证命令后,把命令和结果明确写进对话,让独立判断器能够检查。
|
||||
|
||||
Goal Loop 不是测试框架。真正的验证仍然由工具执行,它只负责判断验证结果是否已经出现在当前工作记录中。
|
||||
|
||||
## 好的完成条件要能检查
|
||||
|
||||
“把代码弄好”太模糊,判断器不知道什么算好。
|
||||
|
||||
更合适的条件会写清三件事:
|
||||
|
||||
1. **结束状态**:最终要达到什么结果;
|
||||
2. **验证方式**:用什么命令或输出证明;
|
||||
3. **限制条件**:完成过程中不能破坏什么。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
/goal 完成登录模块迁移,直到 pytest tests/auth 退出码为 0,
|
||||
并且没有修改 tests/auth 之外的测试文件
|
||||
```
|
||||
|
||||
如果想限制自动执行轮数,使用主循环的全局限制,而不是给 Goal 偷偷加一个固定预算:
|
||||
|
||||
```bash
|
||||
MAX_TURNS=20 python s21_goal_loop/code.py \
|
||||
"/goal 修复类型错误,直到 npm run typecheck 退出码为 0"
|
||||
```
|
||||
|
||||
## 没完成,就回到同一个循环
|
||||
|
||||
判断器认为条件尚未满足时,会给出简短原因:
|
||||
|
||||
```text
|
||||
对话中还没有出现完整测试结果,请运行 pytest tests/auth 并报告退出码。
|
||||
```
|
||||
|
||||
程序把原因加入 `messages[]`,然后在当前 `while` 循环里直接 `continue`。主模型立即开始下一轮,不需要用户再次输入“继续”。
|
||||
|
||||
这里没有单独的 continuation queue。Goal 检查就在主循环的结束位置,未满足时也从这里回到主循环。
|
||||
|
||||
## 后台任务没有结束时,先不要判断
|
||||
|
||||
Workflow、后台命令和其他异步任务可能在主模型结束当前轮时仍在运行。
|
||||
|
||||
这时立即判断通常没有意义,因为关键结果还没有回到对话。Goal Stop hook 返回 `defer`,保留当前 Goal,也不调用判断器。后台任务结束后,宿主把完成通知交给 `submit_background_result()`;通知进入同一个 `messages[]`,主循环再继续。
|
||||
|
||||
Workflow 完成通知没有机械上的特殊权限。它和其他消息一样进入对话,判断器根据其中的实际结果判断条件是否满足。
|
||||
|
||||
## 自动继续也必须有出口
|
||||
|
||||
Goal 本身没有一个默认的“最多 20 轮”。是否满足完成条件,由判断器每轮重新判断。
|
||||
|
||||
但任何自动机制都不能无限占住一次请求。本课在 Stop hook 外保留两道通用出口:
|
||||
|
||||
- 主循环的全局 `max_turns`;
|
||||
- Stop hook 连续阻止结束的次数上限。
|
||||
|
||||
达到上限时,程序把控制权还给用户,但不会把目标伪装成完成,也不会自动清除目标。用户可以查看状态、补充信息后继续,或者主动清除。
|
||||
|
||||
判断器调用失败时也采用同样原则:停止自动续轮,保留目标,并把错误交给用户,而不是在无法判断时宣称成功。
|
||||
|
||||
## 查看、替换和清除
|
||||
|
||||
每个会话同时只有一个活跃 Goal。
|
||||
|
||||
```text
|
||||
/goal
|
||||
```
|
||||
|
||||
查看当前条件、已经判断的次数、经过时间、主 Agent 的 token 使用量和最近一次判断原因。
|
||||
|
||||
```text
|
||||
/goal 新的完成条件
|
||||
```
|
||||
|
||||
直接替换旧 Goal,并立即按新条件开始工作。
|
||||
|
||||
```text
|
||||
/goal clear
|
||||
```
|
||||
|
||||
清除当前 Goal。`stop`、`off`、`reset`、`none` 和 `cancel` 也可以作为清除别名。
|
||||
|
||||
`GoalController.restore()` 可以从宿主保存的 `goal_status` 事件中恢复仍然活跃的 Goal;本课的命令行入口不负责持久化整个会话。已经完成、失败或主动清除的 Goal 不会重新启动。恢复后保留完成条件,但重新计算轮数、时间和 token 使用量。
|
||||
|
||||
## 代码里新增了什么
|
||||
|
||||
这一章没有重写 Agent Loop,只增加了四个小部件:
|
||||
|
||||
| 部件 | 作用 |
|
||||
|---|---|
|
||||
| `GoalState` | 保存条件、判断次数、开始时间和最近原因 |
|
||||
| `PromptGoalEvaluator` | 用独立小模型读取对话并返回判断 |
|
||||
| `GoalController` | 设置、查看、清除 Goal,并实现 Stop hook |
|
||||
| `AgentSession` | 在原来的退出位置接入 Goal 判断 |
|
||||
|
||||
接入点只有几行:
|
||||
|
||||
```python
|
||||
TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"}
|
||||
|
||||
def evidence_text(self):
|
||||
out = []
|
||||
for m in self.transcript[self.active["start_index"]:]:
|
||||
if m.origin.get("kind") == "slash-command": # 1 斜杠命令本身不算
|
||||
continue
|
||||
if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal 命令文本不算
|
||||
continue
|
||||
if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只信可信来源
|
||||
continue
|
||||
out.append(f"{m.role}: {m.content}")
|
||||
return "\n".join(out)
|
||||
decision = await self.goal.evaluate_after_turn(self.messages)
|
||||
if decision.action == "block":
|
||||
continue
|
||||
return SessionResult(text=text, status=decision.action)
|
||||
```
|
||||
|
||||
效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句"我做完了"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场:s15 说协议靠字段不靠理解,s18 说注解是申报、申报可以撒谎,s21 说完成证据只看来源不看内容。
|
||||
|
||||
`goal_satisfied()` 使用确定的关键词匹配,让示例保持离线和可复现。把判断与执行分开,才能守住可信证据边界。
|
||||
|
||||
## 闸门三态:完成/继续/超预算
|
||||
|
||||
`evaluate_after_turn` 每轮跑一次,三种结果:满足条件就清除目标(completed);没满足而且预算还没花完,就往队列塞一条"继续干"的提示,放行下一轮(continuing);预算花完就停(blocked),别让一个永远判不出来的目标无限烧钱。
|
||||
|
||||
```python
|
||||
def evaluate_after_turn(self):
|
||||
g = self.active
|
||||
g["checks"] += 1
|
||||
if self.goal_satisfied():
|
||||
g["status"] = "completed"; self.active = None
|
||||
return "completed" # 达成 -> 清除目标
|
||||
if g["continuation_turns"] < g["max_turns"]:
|
||||
g["continuation_turns"] += 1
|
||||
self.queue.enqueue(
|
||||
value="继续干活,别把这条提醒当成完成证据。",
|
||||
origin={"kind": "active-goal"})
|
||||
return "continuing" # 没达成 -> 塞提示,下一轮
|
||||
g["status"] = "blocked"; self.active = None
|
||||
return "blocked" # 超预算 -> 放行,不再拦
|
||||
```
|
||||
|
||||
那条"继续干"的提示里特意写了"别把这条提醒当成完成证据",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。
|
||||
|
||||
## 继续提示和外部异步消息分开走
|
||||
|
||||
继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。
|
||||
|
||||
```python
|
||||
def dequeue(self, include_goal_continuations=True):
|
||||
...
|
||||
for idx, item in enumerate(self.items):
|
||||
if include_goal_continuations or item["origin"].get("kind") != "active-goal":
|
||||
return self.items.pop(idx)
|
||||
return None
|
||||
```
|
||||
|
||||
为什么要分开?如果同一个消费者把继续提示和外部通知一起取走,后台结果还没到,提醒文本就可能被误当成新证据。分开之后,目标的推进是显式的一步,不会被异步事件带着走。
|
||||
|
||||
## 跑起来看看
|
||||
|
||||
`code.py` 演示了一个 `/goal until tests passed and deploy green`:设了目标之后没有可信证据,闸门一轮轮把它推回去;你直接打 `tests passed` 也不算(来源不可信);直到后台任务发来 `task-notification`,证据到位,才标记完成。还加了一个 `max_turns=2` 的小目标演示超预算拦截。
|
||||
|
||||
```python
|
||||
s.submit("/goal until tests passed and deploy green") # 设目标,窗口在命令之后
|
||||
s.submit("tests passed, trust me") # 普通文本 -> 不算完成
|
||||
s.deliver_host_event("tests passed; deploy green",
|
||||
source="task-notification") # 可信宿主事件 -> 完成
|
||||
```
|
||||
|
||||
`submit()` 只接受普通用户文本。可信标签必须走独立的宿主事件通道,来源由 harness 白名单校验;用户或模型文本不能给自己贴上 `task-notification` 标签。
|
||||
|
||||
## 相对 s20 的变更
|
||||
|
||||
| | s20 Workflow Runtime | s21 Goal Loop |
|
||||
|--|---------------------|---------------|
|
||||
| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(拉回主循环) |
|
||||
| 加在哪 | 工具层:一个 `Workflow` 工具 | 轮次收尾:一道完成闸门 |
|
||||
| 谁决定停 | 脚本跑完就停 | 目标条件对着可信证据判 |
|
||||
| 新增机制 | 脚本 DSL、后台任务、journal/续跑、结构化输出 | 目标闸门、证据信任边界、继续提示分流、预算 |
|
||||
|
||||
s20 是把编排写成脚本、派出去脱离主循环;s21 反过来,是一股力量把控制权重拉回主循环:目标没达成,这一轮就不算结束。两个都不改 s01 那个 `while` 循环,只是从两头给它加约束。
|
||||
|
||||
## 试一下
|
||||
先安装依赖并准备 `.env`:
|
||||
|
||||
```bash
|
||||
python s21_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判
|
||||
pip install -r requirements.txt
|
||||
|
||||
# .env
|
||||
ANTHROPIC_API_KEY=...
|
||||
MODEL_ID=...
|
||||
|
||||
# 可选:给 Goal 判断器使用更小的模型
|
||||
GOAL_EVALUATOR_MODEL_ID=...
|
||||
```
|
||||
|
||||
观察:设了目标之后,每轮结束都有一条 `goal_evaluated`;普通文本判 `satisfied=False`,`task-notification` 来源判 `satisfied=True`;预算花完的时候出 `goal_blocked`。同样一句 `tests passed`,来源不同,结果完全相反。这就是 `/goal` 不会被一句空话糊弄的地方。
|
||||
进入交互模式:
|
||||
|
||||
## 接下来
|
||||
```bash
|
||||
python s21_goal_loop/code.py
|
||||
```
|
||||
|
||||
`/goal` 是"拉回主循环"的一种触发:条件控制。它和 s20 的"脱离主循环"正好成对,一个把工作派出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**
|
||||
然后输入:
|
||||
|
||||
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
|
||||
```text
|
||||
/goal python -m pytest 退出码为 0
|
||||
```
|
||||
|
||||
也可以直接从命令行设置 Goal:
|
||||
|
||||
```bash
|
||||
python s21_goal_loop/code.py "/goal python -m pytest 退出码为 0"
|
||||
```
|
||||
|
||||
## 相对 s20 的变化
|
||||
|
||||
s20 解决“一批工作怎样执行”:哪些步骤并行,结果怎样验证,失败后怎样恢复。
|
||||
|
||||
s21 解决“整件事情是否已经完成”:即使 Workflow 已经结束,结果也可能还没有满足用户的最终要求。Workflow 的结果回到对话后,Goal 判断器再决定是结束还是继续工作。
|
||||
|
||||
两个机制可以单独使用。接到同一个宿主时,Workflow 的完成通知进入会话,Goal Loop 再决定整个任务是否还要继续。
|
||||
|
||||
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->
|
||||
|
||||
Reference in New Issue
Block a user