Loop Engineering 101

忘掉 "agent 是啥" —— 直接看代码。我们从你已经会写的 单次 LLM 调用 开始,一版一版加东西,最后就是一个能收敛的 agent loop。每段代码都能直接抄。

v0你现在写的:一次调用
v1最傻的 while — 会死循环
v2加外部 verifier — 能停了
v3加预算和 stall 检测 — 不烧钱了
v4压缩 state — context 不爆了
v5加 trace — 能调试了
今天的作业

整个例子做同一件事:给定一个任务描述,让 agent 去改一个文件,直到 pytest 通过。 我们不真调 LLM 也不真跑 pytest —— 用一段 假的 LLM假的 shell,你按"Run"就能看到每一版的行为差别。


v0你现在的位置:harness 单次调用

你已经会写这样的东西:

def call_llm(prompt: str) -> str:
    # OpenAI / Anthropic / whatever
    return client.chat.completions.create(
        model="gpt-5",
        messages=[{"role": "user", "content": prompt}],
    ).choices[0].message.content

# Harness = 你为这一次调用准备的所有东西
answer = call_llm("修好 buggy.py,让 pytest 通过。文件内容:\n" + open("buggy.py").read())
print(answer)
为啥不够
模型输出一段自称是"修好的代码",你也不知道对不对,也没跑起来。没有反馈就不是 loop。

v1最傻的 while:会死循环

凭直觉把它包一层 while:

def agent_v1(goal: str):
    history = []
    while True:                          # ← 没有终止条件
        prompt = f"目标: {goal}\n历史:\n" + "\n".join(history)
        action = call_llm(prompt)              # policy: 模型输出下一步动作
        observation = run_shell(action)      # env: 真的去执行
        history.append(f"> {action}\n{observation}")

        if "done" in action.lower():        # ← 让模型自己说"我好了"
            break
    return history
这里已经埋了 3 个雷
  1. 只有 policy 自己判断成功:模型说"done"你就信了。模型爱说 done。
  2. 没有预算:只要模型不说 done,你就一直烧 token。
  3. history 无限增长:跑到第 20 步 prompt 已经 80KB 了,模型开始忽略前面的东西。
跑一下 v1,模型故意 "谦虚" 一点,就是不说 done
(点 Run 看它怎么翻车)

v2加一个 外部 verifier

关键规则:让 policy 决定"下一步做什么",让 verifier 决定"是不是完成了"。 这两个角色永远不要是同一个。

def verify() -> bool:
    # 用可执行信号判断,不是问模型
    result = subprocess.run(["pytest", "-x"], capture_output=True)
    return result.returncode == 0

def agent_v2(goal: str):
    history = []
    while True:
        prompt = f"目标: {goal}\n历史:\n" + "\n".join(history)
        action = call_llm(prompt)
        observation = run_shell(action)
        history.append(f"> {action}\n{observation}")

        if verify():                          # ← 外部信号,不问模型
            return "success", history
        if "done" in action.lower(): break
好多了,但还会烧钱
现在"成功"是真的成功了,但如果模型永远修不好,还是会一直转下去。
v2:verifier 挡住了假 done,但模型这次陷入"每步都改同一行"
(点 Run)

v3加预算 + stall 检测

三个终止器一起上,缺一不可:

MAX_STEPS = 10
STALL_LIMIT = 3

def agent_v3(goal: str):
    history = []
    stall = 0
    prev_obs = None

    for step in range(MAX_STEPS):        # ① 预算终止器
        prompt = f"目标: {goal}\n历史:\n" + "\n".join(history[-5:])
        action = call_llm(prompt)
        observation = run_shell(action)
        history.append(f"> {action}\n{observation}")

        if verify():                          # ② 成功终止器
            return "success", step, history

        if observation == prev_obs:         # ③ stall 终止器
            stall += 1
            if stall >= STALL_LIMIT:
                return "stalled", step, history
        else:
            stall = 0
        prev_obs = observation

    return "budget_exhausted", MAX_STEPS, history
这已经是一个"能安全跑通"的 loop 了
三条腿:successstallbudget。少任何一条都会翻车 —— 少 success 是废物,少 stall 是死循环,少 budget 是无底洞。
v3:同一个"卡住"的模型,现在会被 stall 提前叫停
(点 Run)

v4压缩 state:别把 history 全塞给模型

你可能注意到 v3 里已经悄悄 history[-5:] 截断了。这是廉价做法。正确做法是每一步把新信息挤压成结构化 state:

@dataclass
class State:
    goal: str
    facts: list[str]                   # 我学到的确定的事实
    last_observation: str              # 最近一次的 raw output
    open_hypotheses: list[str]         # 我还不确定的猜测

def update_state(state, action, observation):
    # 用一个便宜的小模型/规则做抽取(不是把 raw 塞回去)
    new_facts = extract_facts(action, observation)   # 例如 regex 抓错误名
    state.facts.extend(new_facts)
    state.last_observation = observation[:500]         # 只留摘要
    return state

def render_prompt(state):
    # prompt 里给模型看的是 state,不是流水账
    return f"""目标: {state.goal}

已确认的事实:
{chr(10).join(f'- {f}' for f in state.facts)}

最近一次执行返回:
{state.last_observation}

下一步动作是什么?"""
为什么这一步很关键
v4:state 里能看到 facts 一步步累积
(点 Run)

v5Trace:不落盘就没法调优

最后一件事,加起来只有 3 行,但决定你能不能优化这个 loop:

import json, time, pathlib

def agent_v5(goal, run_id):
    trace_path = pathlib.Path(f"traces/{run_id}.jsonl")
    trace_path.parent.mkdir(exist_ok=True)
    state = State(goal=goal, facts=[], last_observation="", open_hypotheses=[])

    for step in range(MAX_STEPS):
        t0 = time.time()
        action = call_llm(render_prompt(state))
        observation = run_shell(action)
        state = update_state(state, action, observation)

        with trace_path.open("a") as f:
            f.write(json.dumps({
                "step": step,
                "action": action,
                "observation": observation[:2000],
                "state_facts": state.facts,
                "latency": time.time() - t0,
            }) + "\n")

        if verify(): return "success", state
        # ... stall + budget 同 v3
心法: 跑 20 个不同的输入,然后读 20 份 trace.jsonl。你会立刻看到"哪一步蠢"——那就是下一轮要改的地方。这比在 prompt 里加"你要仔细思考"有用 100 倍。

今天的作业

找一个你现在正在用 harness 单次调用做的事(哪怕很小,比如"生成一段 SQL"、"重命名一批文件"),照下面这个顺序改造:

  1. 写出 verifier:什么样的可执行信号能证明它做对了?(SQL 能跑通?文件都存在?)如果你答不上来,这个任务根本不适合放进 loop —— 那就还是留在 harness。
  2. 写一个 for step in range(10),里面调你原来的函数。
  3. stall 检测:如果连续 3 步 observation 一模一样,abort。
  4. state:一个 dataclass,字段就 goal / facts / last_obs 三个就够。
  5. 每步 trace.jsonl 落盘。
  6. 跑 5-10 个真实输入,读 trace,找一个"看起来最蠢"的点,改它。
一句话记住: Loop engineering 不是"让模型更聪明",是让流程在模型犯蠢时依然收敛。你写的是控制流,不是 prompt。