犀牛鸟 2026 研究笔记 estelledc.github.io

第十一章:Ralph Loop 与可验证 Agent 执行

本章目标:理解为什么 LLM 的”我做完了”不可信,以及 tRPC-Agent-Go 如何用 Ralph Loop 三重验证机制实现真正可靠的 Agent 执行。


11.1 LLM 说”做完了”,你敢信吗?

在前面的章节中,我们深入学习了 ReAct 循环:LLM 思考、调用工具、观察结果、循环往复,直到 LLM 觉得任务完成了,生成一个最终回答。

注意这里的关键词——LLM 觉得

这就是问题所在。LLM 的”完成判断”有三种典型失败模式:

幻觉型完成:LLM 压根没做对,但它自信满满地说”已完成”。你让它写一个排序算法,它写了个有 bug 的版本,然后告诉你”这个归并排序的实现已经完成,时间复杂度 O(n log n)”——代码跑不通,但它的总结写得比论文还漂亮。

偷懒型完成:LLM 觉得差不多就行了。你让它生成一个包含 10 个字段的配置文件,它只写了 6 个字段就宣布完成——因为它”觉得”剩下的字段不太重要。

格式偏移型完成:LLM 在内容上做对了,但输出格式不符合要求。你让它返回 JSON,它返回了 Markdown 代码块包裹的 JSON;你让它只输出代码,它非要加一段”解释说明”。

日常类比:你让一个新人实习生完成一个任务。他走过来跟你说”搞定了!”你会怎么做?

如果是一个你很信任的老员工,你可能就说”好的”然后继续别的事。但如果是第一天入职的实习生——你会去检查一下。不是不信任他,而是确认结果是对的、格式是对的、该交付的都交付了。

Ralph Loop 就是那个”检查一下”的机制。它对 LLM 的态度是:你说做完了不算,我验证过了才算。


11.2 Ralph Loop 的设计理念

11.2.1 名字的来历

Ralph 这个名字来自一个简单的理念:Reliable Agent Loop with Programmatic Handshake——通过程序化握手确认来实现可靠的 Agent 循环。核心思想是:不要让 LLM 自己判断自己是否完成了任务,要让外部的、确定性的验证器来做这个判断。

这体现了一种更深层的设计哲学:在 AI 系统中,信任应该是被赚取的,而不是被假设的。LLM 的输出是概率性的——同一个 prompt 跑两次可能得到不同结果。用概率性的判断来决定任务是否完成,这本身就不靠谱。所以,Ralph Loop 引入了确定性的验证来”锚定”这个不确定的过程。

11.2.2 与 ReAct 循环的关系

在第三章中我们学过 ReAct 循环——那是 Agent 的”内层循环”:

内层循环(ReAct):
  LLM Think → Tool Call → Observation → LLM Think → ... → LLM 说"完成了"

ReAct 循环的终止条件是LLM 自己决定的:当 LLM 不再发起 tool call,而是生成一段自然语言回答时,ReAct 循环就结束了。

Ralph Loop 是”外层循环”:

外层循环(Ralph):
  ReAct 循环执行完毕 → LLM 说"完成了" → 验证器检查 → 
  如果验证通过 → 真正完成
  如果验证失败 → 把失败原因告诉 LLM → 重新进入 ReAct 循环

两个循环的嵌套关系用日常类比来说就是:

内层循环像一个厨师做菜的过程——他在厨房里切菜、炒菜、调味、摆盘,这个过程中他自己判断什么时候翻锅、什么时候加盐、什么时候装盘。外层循环像餐厅经理的质检——厨师端出来说”做好了”,经理看一眼:摆盘整齐吗?分量对吗?温度够吗?不过关就退回去重做。

厨师自己的判断很重要(不然菜都做不出来),但最终是否上桌由经理说了算。

11.2.3 为什么不直接把验证逻辑放进 ReAct 循环?

你可能会问:既然要验证,为什么不在 ReAct 循环里加一个”验证工具”呢?让 LLM 在觉得做完后自己调用验证工具,如果不过就继续。

这样做有一个根本问题:你在要求一个不可信的判断者来决定是否需要验证自己的判断。这是一个循环论证——你不信任 LLM 的完成判断,但你又让 LLM 来决定什么时候需要验证。

实际中会发生什么:LLM 有时候会跳过验证工具(”这个简单,不用验证了”),有时候会在验证失败后反复修改同一个错误而不解决根本问题,有时候甚至会”理解”验证工具的返回值然后在回答中谎称通过了。

把验证放在外层循环,就剥夺了 LLM 跳过验证的权力。每次 ReAct 循环结束,验证都必定执行,这是由程序逻辑保证的,不是由 LLM 的”意愿”保证的。


11.3 三重验证机制

tRPC-Agent-Go 的 Ralph Loop 实现了三种验证器,它们并非三选一,而是全部叠加——只有三道关卡全部通过,任务才算完成。

11.3.1 CompletionPromise:让 LLM 先”承诺”

机制:要求 LLM 在输出中包含一个特殊的标记 <promise>xxx</promise>,LLM 必须在这个标记里显式声明自己完成了什么。

<!-- LLM 的输出示例 -->
我已经完成了用户要求的归并排序实现。

<promise>
1. 实现了 mergeSort 函数,支持 int 数组排序
2. 实现了 merge 辅助函数
3. 添加了 5 个单元测试用例
4. 所有测试通过
</promise>

验证方式:Ralph Loop 检查 LLM 的输出文本中是否存在 <promise> 标记。如果 LLM 没有写这个标记,就认为任务未完成,把”你必须用 <promise> 标记声明你完成了什么”这个反馈追加到 session 中,让 LLM 重来。

为什么这很精妙?

表面上看,这只是在检查一个 XML 标记存不存在。但实际上它做了一件很重要的事——强制 LLM 进行显式的自我审视

当 LLM 被要求写出 <promise> 标记时,它必须回顾自己的输出,把”我做了什么”用结构化的方式列出来。这个过程本身会降低幻觉的概率——因为 LLM 如果什么都没做,它很难编出一个看起来合理的 promise(虽然不是不可能,但比单纯说”完成了”的门槛高多了)。

类比:考试交卷前,老师要求你在试卷最后写一段”答题回顾”——你做了哪些题、用了什么方法。这个动作本身会让你发现漏做的题或者明显的错误。

代码层面的实现思路

// CompletionPromise 验证器的核心逻辑
type CompletionPromiseVerifier struct {
    // promise 标记是否必需
    Required bool
}

func (v *CompletionPromiseVerifier) Verify(output string) VerifyResult {
    // 用正则提取 <promise>...</promise> 内容
    promisePattern := regexp.MustCompile(`(?s)<promise>(.*?)</promise>`)
    matches := promisePattern.FindStringSubmatch(output)

    if len(matches) < 2 {
        return VerifyResult{
            Passed:   false,
            Feedback: "你的回答中缺少 <promise> 标记。请在回答末尾用 <promise> 标记声明你完成了哪些工作。",
        }
    }

    promiseContent := strings.TrimSpace(matches[1])
    if len(promiseContent) == 0 {
        return VerifyResult{
            Passed:   false,
            Feedback: "<promise> 标记内容为空。请明确声明你完成了什么。",
        }
    }

    return VerifyResult{
        Passed:        true,
        PromiseContent: promiseContent,
    }
}

11.3.2 VerifyCommand:用 Shell 命令做硬验证

机制:配置一个 shell 命令,执行后看 exit code。exit 0 = 通过,非零 = 失败。命令的 stdout/stderr 作为反馈返回给 LLM。

// 配置示例
ralphConfig := RalphConfig{
    VerifyCommand: "cd /workspace && go test ./... -v",
    MaxRetries:    5,
}

这是最硬核的验证方式。

为什么说它”硬核”?因为 shell 命令的执行结果是完全确定性的go test 要么全绿(exit 0),要么有失败(exit 非零)。不存在”差不多通过”或者”大部分通过”这种模糊地带。

实际场景中的命令示例:

# 代码生成场景:跑测试
go test ./... -v

# 代码格式检查:lint 必须通过
golangci-lint run ./...

# 文档生成场景:检查必要文件是否存在
test -f output/report.md && grep -q "## 结论" output/report.md

# 数据处理场景:验证输出 JSON 格式合法
python -c "import json; json.load(open('output.json'))"

# 构建场景:项目必须能编译
go build ./...

验证失败时发生什么?

假设 LLM 生成了一段代码,go test 跑出了 3 个失败。Ralph Loop 会把完整的测试输出追加到 session 中,让 LLM 看到具体哪个测试挂了、错误信息是什么,然后让它修改代码重新来过。

=== 验证命令执行失败 ===
命令: go test ./... -v
退出码: 1
输出:
--- FAIL: TestMergeSort (0.00s)
    sort_test.go:15: expected [1 2 3 4 5], got [1 2 4 3 5]
--- FAIL: TestMergeSortEmpty (0.00s)
    sort_test.go:22: expected [], got <nil>

FAIL

请根据上述测试失败信息修改你的代码,使所有测试通过。

这个过程非常像TDD(测试驱动开发)中的 Red-Green 循环——先看测试失败(Red),再改代码让测试通过(Green)。只不过”改代码”这一步是 LLM 在做。

最大重试次数

显然,不能让 LLM 无限重试——万一它就是修不好呢?所以 Ralph Loop 配置了 MaxRetries。超过最大重试次数后,任务标记为失败,返回”LLM 在 N 次尝试后仍未通过验证”的错误。

这也很合理:如果一个实习生改了 5 遍还改不对,你不会让他改第 6 遍——你会介入亲自看问题出在哪里。

11.3.3 Verifiers:自定义验证器接口

机制:提供一个 Go 接口,开发者可以实现任意复杂的验证逻辑。

// Verifier 接口定义
type Verifier interface {
    // Name 返回验证器名称,用于日志和反馈
    Name() string
    // Verify 接收 LLM 的完整输出,返回验证结果
    Verify(ctx context.Context, output string) (*VerifyResult, error)
}

type VerifyResult struct {
    Passed   bool
    Feedback string  // 失败时告诉 LLM 的信息
    Details  any     // 额外详情,用于日志
}

这是最灵活的验证方式。你可以做任何事情:

// 示例 1:检查输出中必须包含某些关键词
type KeywordVerifier struct {
    RequiredKeywords []string
}

func (v *KeywordVerifier) Name() string { return "keyword-check" }

func (v *KeywordVerifier) Verify(ctx context.Context, output string) (*VerifyResult, error) {
    var missing []string
    for _, kw := range v.RequiredKeywords {
        if !strings.Contains(output, kw) {
            missing = append(missing, kw)
        }
    }
    if len(missing) > 0 {
        return &VerifyResult{
            Passed:   false,
            Feedback: fmt.Sprintf("输出中缺少以下关键内容: %s", strings.Join(missing, ", ")),
        }, nil
    }
    return &VerifyResult{Passed: true}, nil
}

// 示例 2:调用外部 API 验证
type APIValidationVerifier struct {
    Endpoint string
}

func (v *APIValidationVerifier) Name() string { return "api-validation" }

func (v *APIValidationVerifier) Verify(ctx context.Context, output string) (*VerifyResult, error) {
    resp, err := http.Post(v.Endpoint, "application/json",
        strings.NewReader(output))
    if err != nil {
        return nil, fmt.Errorf("validation API call failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        body, _ := io.ReadAll(resp.Body)
        return &VerifyResult{
            Passed:   false,
            Feedback: fmt.Sprintf("API 验证失败: %s", string(body)),
        }, nil
    }
    return &VerifyResult{Passed: true}, nil
}

// 示例 3:Schema 验证——检查输出是否符合 JSON Schema
type JSONSchemaVerifier struct {
    Schema *jsonschema.Schema
}

func (v *JSONSchemaVerifier) Name() string { return "json-schema" }

func (v *JSONSchemaVerifier) Verify(ctx context.Context, output string) (*VerifyResult, error) {
    // 从输出中提取 JSON 部分
    jsonStr := extractJSON(output)
    if jsonStr == "" {
        return &VerifyResult{
            Passed:   false,
            Feedback: "输出中没有找到有效的 JSON 内容",
        }, nil
    }
    
    err := v.Schema.Validate(jsonStr)
    if err != nil {
        return &VerifyResult{
            Passed:   false,
            Feedback: fmt.Sprintf("JSON 不符合预期 Schema: %v", err),
        }, nil
    }
    return &VerifyResult{Passed: true}, nil
}

11.3.4 三者叠加的完整验证流程

三重验证不是三选一,而是串行执行,全部通过才算完成

LLM 输出
  │
  ├─→ 1. CompletionPromise 检查
  │     └─ 输出中有 <promise> 标记?
  │         ├─ 没有 → 失败,反馈 LLM
  │         └─ 有 → 继续
  │
  ├─→ 2. VerifyCommand 执行
  │     └─ shell 命令 exit 0?
  │         ├─ 非零 → 失败,把 stderr 反馈给 LLM
  │         └─ 零 → 继续
  │
  ├─→ 3. Verifiers 逐个执行
  │     └─ 每个验证器都 Passed?
  │         ├─ 有任何一个 Failed → 失败,反馈 LLM
  │         └─ 全部 Passed → 继续
  │
  └─→ 全部通过 → 任务真正完成

为什么要叠加?因为每种验证覆盖的维度不同:

类比:一个建筑项目的验收也是多维度的——施工方自己的竣工报告(CompletionPromise)、第三方质检机构的结构安全测试(VerifyCommand)、甲方的设计符合度审核(Verifiers)。三方都说 OK 才能交付。


11.4 验证失败的反馈闭环

验证的意义不只在于”发现问题”,更在于”给 LLM 修正的机会”。Ralph Loop 的反馈机制是这样工作的:

11.4.1 反馈追加到 Session

当验证失败时,Ralph Loop 会构造一条反馈消息,追加到当前的 session(对话历史)中。这条消息会包含:

type VerificationFeedback struct {
    // 哪个验证器失败了
    VerifierName string
    // 验证器返回的具体反馈
    Feedback     string
    // 当前是第几次重试
    RetryCount   int
    // 最多还能重试几次
    RemainingRetries int
}

func (r *RalphLoop) buildFeedbackMessage(fb *VerificationFeedback) Message {
    return Message{
        Role: "user",  // 以 user 身份追加,让 LLM 把它当作"新指令"
        Content: fmt.Sprintf(
            `[验证失败 - %s] (第 %d/%d 次尝试)

%s

请根据上述反馈修改你的输出,确保通过验证。`,
            fb.VerifierName,
            fb.RetryCount,
            fb.RetryCount + fb.RemainingRetries,
            fb.Feedback,
        ),
    }
}

注意这里的一个设计细节:反馈消息的 Role 是 "user"。为什么不用 "system" 或者自定义角色?

因为 LLM 最”听话”的对象就是 user。system prompt 设定了行为框架,但具体指令来自 user。把验证反馈以 user 身份发送,就是在告诉 LLM”用户对你的输出不满意,要你改”——这比一个系统级通知更能驱动 LLM 做出修改。

11.4.2 上下文累积效应

每次验证失败、追加反馈、LLM 重做,这些内容都会累积在 session 中。这意味着:

  1. LLM 能看到自己之前的失败尝试——避免重复犯同样的错误
  2. LLM 能看到每个验证器的具体反馈——知道问题出在哪里
  3. LLM 知道自己还剩几次机会——增加紧迫感(虽然 LLM 未必真的”感受”到紧迫,但这个信息确实会影响它的输出倾向)

但这也带来一个问题:context window 会越来越大。每次重试都往 session 里加内容——前一次的代码、测试报告、反馈消息、新的代码、新的测试报告……几轮下来可能就占满了 context。

所以 MaxRetries 的设置要合理。一般来说 3-5 次是一个好的范围——足够 LLM 迭代修改,但不会撑爆 context。

11.4.3 反馈质量决定修复效率

验证反馈写得好不好,直接决定 LLM 能不能改对。

差的反馈

验证失败。请修改你的代码。

这种反馈没有给 LLM 任何有用的信息——它不知道哪里错了、怎么错了、应该怎么改。这就像一个 code review 只写”这段不行,重写”——让人无从下手。

好的反馈

验证失败 [go test]:
--- FAIL: TestMergeSort_DuplicateElements (0.00s)
    sort_test.go:32: input: [3, 1, 2, 1, 3]
                     expected: [1, 1, 2, 3, 3]
                     got:      [1, 2, 3]
    
分析:你的 merge 函数在合并时跳过了重复元素。
检查 merge 函数中的比较条件,确保 <= 而非 < 。

所以,设计 VerifyCommand 和 Verifiers 时,输出清晰的诊断信息非常重要。测试框架的详细输出(-v 选项)、lint 工具的错误行号和建议、schema 验证的具体字段差异——这些都是帮助 LLM 快速修复的关键信息。


11.5 Ralph Loop 的完整执行流程

把所有组件拼在一起,完整流程如下:

func (r *RalphLoop) Execute(ctx context.Context, task string) (*Result, error) {
    // 1. 初始化 session,把用户任务加入
    session := NewSession()
    session.AddMessage(Message{Role: "user", Content: task})

    for attempt := 0; attempt < r.maxRetries; attempt++ {
        // 2. 执行内层 ReAct 循环
        output, err := r.reactLoop.Run(ctx, session)
        if err != nil {
            return nil, fmt.Errorf("ReAct loop failed: %w", err)
        }

        // 3. 三重验证
        allPassed := true
        var feedbacks []VerificationFeedback

        // 3a. CompletionPromise 检查
        if r.promiseVerifier != nil {
            result := r.promiseVerifier.Verify(output)
            if !result.Passed {
                allPassed = false
                feedbacks = append(feedbacks, VerificationFeedback{
                    VerifierName: "CompletionPromise",
                    Feedback:     result.Feedback,
                })
            }
        }

        // 3b. VerifyCommand 执行
        if allPassed && r.verifyCommand != "" {
            cmdResult, err := r.runVerifyCommand(ctx)
            if err != nil || cmdResult.ExitCode != 0 {
                allPassed = false
                feedbacks = append(feedbacks, VerificationFeedback{
                    VerifierName: "VerifyCommand",
                    Feedback:     fmt.Sprintf("命令 '%s' 失败\n退出码: %d\n输出:\n%s",
                        r.verifyCommand, cmdResult.ExitCode, cmdResult.Output),
                })
            }
        }

        // 3c. 自定义验证器
        if allPassed {
            for _, v := range r.verifiers {
                vr, err := v.Verify(ctx, output)
                if err != nil {
                    return nil, fmt.Errorf("verifier %s error: %w", v.Name(), err)
                }
                if !vr.Passed {
                    allPassed = false
                    feedbacks = append(feedbacks, VerificationFeedback{
                        VerifierName: v.Name(),
                        Feedback:     vr.Feedback,
                    })
                    break  // 一个失败就不再继续检查后面的
                }
            }
        }

        // 4. 全部通过?
        if allPassed {
            return &Result{
                Output:   output,
                Attempts: attempt + 1,
                Status:   StatusCompleted,
            }, nil
        }

        // 5. 验证失败 → 构造反馈 → 追加到 session → 重新循环
        for _, fb := range feedbacks {
            fb.RetryCount = attempt + 1
            fb.RemainingRetries = r.maxRetries - attempt - 1
            session.AddMessage(r.buildFeedbackMessage(&fb))
        }
    }

    // 6. 超过最大重试次数
    return &Result{
        Status:   StatusFailed,
        Attempts: r.maxRetries,
        Error:    fmt.Errorf("exceeded max retries (%d) without passing verification", r.maxRetries),
    }, nil
}

这段代码的关键点在第 5 步:验证失败后不是报错退出,而是追加反馈、继续循环。这形成了一个”做→检查→改→再检查”的闭环,直到通过或者用完重试次数。


11.6 实战场景分析

11.6.1 场景一:代码生成

这是 Ralph Loop 最经典的使用场景。

任务:”给我写一个 Go 的 LRU Cache 实现,包含 Get、Put、Delete 方法。”

Ralph 配置

ralph := NewRalphLoop(
    WithVerifyCommand("cd /workspace && go test ./lru/... -v -count=1"),
    WithMaxRetries(5),
    WithPromiseRequired(true),
    WithVerifiers(
        &KeywordVerifier{
            RequiredKeywords: []string{
                "func (c *LRUCache) Get",
                "func (c *LRUCache) Put",
                "func (c *LRUCache) Delete",
            },
        },
    ),
)

执行过程

第 1 轮:LLM 生成 LRU Cache 代码。测试挂了 2 个——TestLRU_EvictionTestLRU_Delete_NonExistent。反馈返回完整的测试失败信息。

第 2 轮:LLM 看到测试失败,修改了 eviction 逻辑(双向链表的删除有 bug)。TestLRU_Delete_NonExistent 还是挂了——忘了处理 key 不存在的情况。

第 3 轮:LLM 加了 key 存在性检查。全部测试通过。CompletionPromise 中声明了三个方法都已实现。KeywordVerifier 确认三个方法签名都存在。任务完成。

这里的关键价值是:如果没有 Ralph Loop,LLM 在第 1 轮就会说”已完成”,你拿到的代码是有 bug 的。有了 Ralph Loop,LLM 被迫迭代修改,最终交付的代码是经过测试验证的。

11.6.2 场景二:文档生成

任务:”生成一份 API 文档,必须包含概述、认证方式、接口列表、错误码、变更日志五个章节。”

Ralph 配置

ralph := NewRalphLoop(
    WithVerifyCommand(`test -f output/api-doc.md`),
    WithMaxRetries(3),
    WithPromiseRequired(true),
    WithVerifiers(
        &KeywordVerifier{
            RequiredKeywords: []string{
                "## 概述",
                "## 认证方式",
                "## 接口列表",
                "## 错误码",
                "## 变更日志",
            },
        },
        &MinLengthVerifier{
            MinWords: 500,
        },
    ),
)

执行过程

第 1 轮:LLM 生成了文档,但只写了概述、接口列表、错误码三个章节。KeywordVerifier 发现缺少”认证方式”和”变更日志”。反馈告诉 LLM 缺了哪两个章节。

第 2 轮:LLM 补上了缺失的章节。全部验证通过。

如果没有 Ralph Loop,你拿到的文档很可能缺少某些章节——LLM 的”偷懒型完成”正是这种场景。

11.6.3 场景三:数据处理

任务:”把 input.csv 转换为 JSON 格式,每条记录必须包含 id、name、email 三个字段,输出到 output.json。”

Ralph 配置

ralph := NewRalphLoop(
    WithVerifyCommand(`python3 -c "
import json, sys
data = json.load(open('output.json'))
assert isinstance(data, list), 'root must be array'
for i, item in enumerate(data):
    for key in ['id', 'name', 'email']:
        assert key in item, f'record {i} missing field: {key}'
print(f'Validated {len(data)} records')
    "`),
    WithMaxRetries(3),
    WithPromiseRequired(true),
)

这个例子展示了 VerifyCommand 的灵活性——验证脚本可以是任何语言、任何复杂度。只要 exit code 是 0 就通过。

11.6.4 场景四:多步骤工作流

任务:”分析 server.log 中的错误模式,生成一份报告,包含 Top 5 错误类型、每种错误的首次和末次出现时间、建议修复优先级。”

ralph := NewRalphLoop(
    WithVerifyCommand("test -f report.md"),
    WithMaxRetries(4),
    WithPromiseRequired(true),
    WithVerifiers(
        &KeywordVerifier{
            RequiredKeywords: []string{"Top 5", "首次出现", "末次出现", "修复优先级"},
        },
        &CustomVerifier{
            Name_: "report-structure",
            VerifyFunc: func(ctx context.Context, output string) (*VerifyResult, error) {
                content, err := os.ReadFile("report.md")
                if err != nil {
                    return &VerifyResult{Passed: false, Feedback: "report.md 不存在或无法读取"}, nil
                }
                // 检查是否有至少5个错误类型的分析
                sections := strings.Count(string(content), "### 错误类型")
                if sections < 5 {
                    return &VerifyResult{
                        Passed:   false,
                        Feedback: fmt.Sprintf("报告中只分析了 %d 种错误类型,要求至少 5 种", sections),
                    }, nil
                }
                return &VerifyResult{Passed: true}, nil
            },
        },
    ),
)

这个场景展示了 Ralph Loop 不仅适用于代码——任何可以用程序化方式验证的产出物都适用。


11.7 CompletionPromise 的深层意义

CompletionPromise 值得单独拿出来深入讨论,因为它体现了一个在 AI 系统设计中非常重要的原则:让 AI 的意图显式化

11.7.1 隐式意图 vs 显式意图

普通的 ReAct 循环中,LLM 的”完成意图”是隐式的——它通过”不再调用工具”来表达完成,而不是通过一个明确的声明。这就像一个人做完事情后默默离开,而不是走过来说”我做完了,具体做了这些”。

CompletionPromise 把隐式意图变成了显式意图。它要求 LLM 不仅要做完事,还要”站起来汇报”——用结构化的方式列出自己的完成声明。

11.7.2 Promise 与验证的对照

更进一步,Promise 的内容可以和其他验证器的结果对照。比如 LLM 在 Promise 中说”所有测试通过”,但 VerifyCommand 跑测试却有 2 个失败——这种”说一套做一套”可以被精确地抓出来。

// 高级用法:对比 Promise 和实际验证结果
type PromiseConsistencyVerifier struct {
    ExpectedClaims []string
}

func (v *PromiseConsistencyVerifier) Verify(ctx context.Context, output string) (*VerifyResult, error) {
    promise := extractPromise(output)
    
    // 检查 promise 中是否包含了所有必要的声明
    var unclaimed []string
    for _, expected := range v.ExpectedClaims {
        if !strings.Contains(promise, expected) {
            unclaimed = append(unclaimed, expected)
        }
    }
    
    if len(unclaimed) > 0 {
        return &VerifyResult{
            Passed: false,
            Feedback: fmt.Sprintf(
                "你的 <promise> 中未声明以下完成项: %s\n"+
                "如果确实完成了这些工作,请在 <promise> 中明确声明。"+
                "如果未完成,请先完成再声明。",
                strings.Join(unclaimed, ", "),
            ),
        }, nil
    }
    return &VerifyResult{Passed: true}, nil
}

11.7.3 对齐问题的微观体现

从更宏观的视角看,CompletionPromise 是 AI 对齐(Alignment)问题的一个微观体现。对齐的核心挑战是:AI 说它在做 X,实际上它在做 Y。CompletionPromise + VerifyCommand 的组合就是一个实用的对齐验证方案——让 AI 声明它做了什么(Promise),然后独立验证这个声明是否属实(VerifyCommand)。

当然,这只能验证可以程序化检查的部分。对于需要人类判断的维度(比如”代码是否优雅”、”文档是否易懂”),仍然需要 Human-in-the-loop。但对于可自动化验证的部分,Ralph Loop 提供了一个很好的基础设施。


11.8 Ralph Loop vs Agent Benchmark/Evaluation

读到这里你可能会想:这不就是给 Agent 跑测试吗?和 SWE-bench 那种 Agent benchmark 有什么区别?

区别很大,关键在于执行时机目的

维度 Ralph Loop Agent Benchmark (如 SWE-bench)
执行时机 运行时(Runtime) 开发时(Development time)
目的 保证单次任务结果正确 评估 Agent 系统的整体能力
谁在用 Agent 自身的执行循环 Agent 的开发者/研究者
失败处理 给 LLM 反馈让它改 记录指标,不干预
覆盖范围 当前任务的验收标准 标准化测试集
可定制性 每个任务可以有不同的验证器 通常是统一的评估协议

类比:Ralph Loop 像驾校路考的考官——你在路上开,考官实时判断你有没有违规、有没有停到位。Agent benchmark 像驾照考试的通过率统计——用 1000 个学员的考试成绩来评估这个驾校教得好不好。

两者不是替代关系,而是互补的。你可以用 Ralph Loop 来提高单次执行的成功率,同时用 benchmark 来评估你的 Ralph Loop 配置(验证器组合、重试次数等)是否最优。

一个有趣的思路:你甚至可以用 benchmark 的方式来调优 Ralph Loop 本身——比如在 100 个代码生成任务上分别测试 MaxRetries=3 和 MaxRetries=5 的成功率差异,或者测试不同 VerifyCommand 对最终代码质量的影响。


11.9 设计 Ralph Loop 的实践建议

11.9.1 验证命令的选择原则

设计 VerifyCommand 时,遵循这几个原则:

确定性:相同输入总是产生相同的通过/失败结果。避免依赖网络请求、随机种子或时间戳的验证命令。

# 好:确定性验证
go test ./... -count=1

# 差:依赖外部服务,可能因网络问题误判
curl -f https://validator.example.com/check

快速执行:验证命令可能执行多次(每次重试都会跑),所以要快。几秒钟以内最理想。

# 好:毫秒级完成
go vet ./...

# 差:耗时太长,N 次重试成本太高
go test -race -benchmem -count=10 ./...

诊断信息丰富:stderr/stdout 要有足够信息帮助 LLM 定位问题。

# 好:详细的失败信息
go test -v ./...  # -v 打印每个测试的详情

# 差:只有 pass/fail,不知道哪里错了
go test ./... 2>/dev/null; echo $?

11.9.2 MaxRetries 的调优

MaxRetries 的最佳值取决于几个因素:

任务复杂度:简单任务(如格式转换)2-3 次够用;复杂任务(如算法实现)可能需要 4-5 次。

Context window 预算:每次重试都会增加 context 占用。如果 LLM 的 context window 是 128K tokens,原始任务 prompt 占了 10K,每次重试平均增加 5K(代码 + 测试输出 + 反馈),那 MaxRetries 不宜超过 (128K - 10K) / 5K ≈ 23 次。但实际上远到不了这个上限——超过 5-6 次,LLM 的注意力就会分散在太多历史信息中,修改质量反而下降。

成本约束:每次重试都是一次完整的 LLM 调用,意味着更多的 token 消费和延迟。生产环境中需要在”结果质量”和”成本/延迟”之间找平衡。

经验值:大多数场景下,MaxRetries = 3 是一个好的默认值。如果是关键任务(必须成功),可以设到 5。超过 5 次还修不好的任务,通常说明 prompt 或工具设计有问题,而不是重试次数不够。

11.9.3 避免验证器过于严格

验证器太严格也是一个坑。如果验证条件极度苛刻,LLM 可能永远无法通过——不是因为它做得不好,而是因为验证器的”正确标准”定义得不合理。

// 过于严格的验证:要求输出和参考答案完全一致
type ExactMatchVerifier struct {
    Expected string
}

func (v *ExactMatchVerifier) Verify(ctx context.Context, output string) (*VerifyResult, error) {
    if output != v.Expected {
        return &VerifyResult{
            Passed:   false,
            Feedback: "输出与预期不完全匹配",
        }, nil
    }
    return &VerifyResult{Passed: true}, nil
}

这个验证器有什么问题?LLM 的输出几乎不可能和一个固定字符串完全一致——多一个空格、换一种说法就会失败。好的验证应该关注语义正确性而非字面一致性

11.9.4 组合使用的最佳实践

一个生产级的 Ralph Loop 配置通常是这样的:

ralph := NewRalphLoop(
    // 基础配置
    WithMaxRetries(3),
    WithPromiseRequired(true),
    
    // 硬验证:代码必须能编译、测试必须通过
    WithVerifyCommand("cd /workspace && go build ./... && go test ./... -v -count=1"),
    
    // 软验证:业务规则检查
    WithVerifiers(
        // 检查代码风格
        &LintVerifier{Command: "golangci-lint run ./..."},
        // 检查文档注释
        &DocCommentVerifier{MinCoverage: 0.8},
        // 检查安全最佳实践
        &SecurityVerifier{Rules: defaultSecurityRules},
    ),
)

硬验证用 VerifyCommand(一票否决),软验证用 Verifiers(可以根据优先级排序)。


11.10 对”可验证 AI”理念的展望

Ralph Loop 体现的”可验证 AI”理念,在 2024-2025 年正在成为 Agent 设计的主流共识。

11.10.1 从”信任”到”验证”的范式转移

早期的 LLM 应用倾向于”信任模型的输出”——生成什么用什么。这在简单场景下没问题,但在需要高可靠性的场景下(代码生成、数据处理、自动化操作)就不够了。

Ralph Loop 代表的是一种范式转移:Trust but Verify(信任但验证)。你可以让 LLM 自由发挥(内层 ReAct 循环不限制),但最终结果必须通过验证(外层 Ralph 循环兜底)。

这种模式在软件工程中早已是常识——CI/CD pipeline 就是”开发者写代码(自由发挥)→ 自动化测试验证(程序化兜底)”。Ralph Loop 把同样的理念搬到了 AI Agent 领域。

11.10.2 可验证 AI 的三个层次

  1. 输出格式可验证:检查 JSON schema、必要字段、数据类型。这是最基础的,Ralph Loop 的 Verifiers 已经覆盖。
  2. 行为正确性可验证:检查 Agent 的操作序列是否符合预期。不仅看最终输出,还看过程中有没有做错事(比如删了不该删的文件)。这需要 Agent 执行的审计日志。
  3. 推理逻辑可验证:检查 Agent 的”思考过程”是否合理。这是最难的——目前主要依靠人工审查 Chain of Thought,尚未完全自动化。

Ralph Loop 主要覆盖第 1 层和部分第 2 层(通过 VerifyCommand 检查副作用)。第 3 层是未来的研究方向。

11.10.3 其他框架的验证机制对比

tRPC-Agent-Go 的 Ralph Loop 并不是唯一的 Agent 验证方案:

LangGraph 的方式:通过 conditional_edge 实现。在图中添加一个”验证节点”,根据验证结果决定是重新执行还是结束。本质上和 Ralph Loop 类似,但表达为图的结构而非独立的循环层。

# LangGraph 风格的验证
def should_retry(state):
    if state["test_passed"]:
        return "end"
    if state["retry_count"] >= max_retries:
        return "fail"
    return "generate"  # 回到代码生成节点

graph.add_conditional_edges("verify", should_retry)

Claude Code 的方式:Claude Code 没有显式的 Ralph Loop,但它有一个隐式的”人类验证循环”——代码生成后会展示给用户,用户说”这不对”就是验证失败。这种 Human-in-the-loop 的方式更灵活但更慢、更贵(需要人类时间)。

AutoGen 的方式:通过 ConversableAgent 的 is_termination_msg 函数来判断是否完成。可以实现类似 CompletionPromise 的效果,但没有 VerifyCommand 这样的硬验证层。

tRPC-Agent-Go 的 Ralph Loop 的特色在于:把三种验证维度(自我声明 + 命令验证 + 自定义验证)组合成一个统一的框架,开箱即用,同时保持足够的灵活性。

11.10.4 展望:Formal Verification 与 AI Agent

更远的未来,也许会看到形式化验证(Formal Verification)技术和 AI Agent 的结合。形式化验证能够数学证明程序满足某些性质——如果能把 Agent 的行为约束表达为形式化规约,就可以用定理证明器来验证 Agent 的每一步操作是否合法。

这当然是非常远期的愿景。但 Ralph Loop 代表的”程序化验证”思路,是从”完全信任 AI”到”数学证明 AI 正确性”这条路上的重要一步。


11.11 Ralph Loop 的 732 行代码意味着什么

tRPC-Agent-Go 中 Ralph Loop 相关的代码大约 732 行。这个体量告诉我们:

验证不是免费的。实现一个完善的验证循环需要处理很多细节——验证器的接口设计、反馈消息的构造、重试计数、超时处理、context 管理、错误传播、日志记录。这些都不是”加个 if 判断”就能搞定的。

验证的代码量和核心逻辑差不多。ReAct 循环核心大约 2000 多行,验证循环有 732 行。这意味着在 tRPC-Agent-Go 的设计者看来,”验证”的重要性至少是”执行”的 1/3。这个比例在很多生产系统中都是类似的——测试代码通常和业务代码差不多长。

大部分代码在处理边界情况。核心验证逻辑也许只需要 100 行,但处理”验证器返回 error 怎么办”、”验证命令执行超时怎么办”、”重试时 context 满了怎么办”这些边界情况,占据了大部分代码。这也是为什么要用框架而不是自己手写的原因——边界情况太容易遗漏了。


思考题

读完本章后,请尝试回答以下问题。不急着看答案——先用自己的理解想一想,想不通再往后读或来问我。

Q1(设计取舍):如果你正在设计一个文本摘要 Agent(把长文章压缩为 200 字摘要),你会怎么设计 VerifyCommand 和 Verifiers?纯文本摘要不像代码那样有测试可以跑——你需要验证什么?能做到什么程度的自动化验证?哪些方面必须靠人类判断?

Q2(循环论证):11.2.3 节说”让 LLM 自己决定是否需要验证”是循环论证。但在实际中,有些成功的 Agent 系统确实用了”LLM-as-judge”(让另一个 LLM 来判断输出质量)。你觉得”LLM-as-judge”和”把验证工具放进 ReAct 循环”有什么本质区别?为什么前者更靠谱一些?

Q3(实战设计):假设你在用 Ralph Loop 做一个”自动修复 CI 失败”的 Agent——它接收 CI 失败的日志,生成修复代码,然后用 Ralph Loop 验证修复是否有效。VerifyCommand 显然就是重新跑 CI。但问题是:CI 可能跑 10 分钟。你怎么在”验证可靠性”和”执行效率”之间找平衡?(提示:考虑分层验证——快速预检 + 完整验证)

Q4(边界探索):Ralph Loop 对所有 Agent 任务都有效吗?想出一个 Ralph Loop 难以发挥作用的场景,解释为什么,并思考可能的替代方案。

Q5(架构思考):如果把 Ralph Loop 和第四章学的 Team 多 Agent 结合——一个 Coordinator Agent 分配子任务给多个 Worker Agent,每个 Worker 都有自己的 Ralph Loop——会带来什么新的挑战?比如,如果 Worker A 的 Ralph Loop 重试了 3 次才通过,但 Worker B 依赖 Worker A 的输出已经用了第 1 次的(错误的)结果,怎么办?


上一章:第十章 跨框架对比——生产就绪度与协议生态

下一章:第十二章 Memory 设计对比与演进

返回目录:Agent 框架深度导读系列