Agent 源码看不懂?先跟着一条消息走一遍

12,018 字#pi-rust #Rust #Agent #源码解读

你在编码 Agent 里输入“读一下配置文件,告诉我超时时间是多少”,按下 Enter。过了一会儿,它打开文件,把结果告诉你。

看起来只是一次问答。但谁把文字交给模型?文件是谁读的?工具读完以后,模型又怎么知道文件内容?

读 Agent 源码最容易卡在这里:函数一个个都认识,连起来却不知道消息走到了哪一步。有些函数叫 prompt_text,有些叫 run_core,还有一个叫 run_agent_loop。到底哪一个真的请求了模型?

先不背名字。我们就拿上面这条消息做例子,跟着它从输入框走到模型,再从工具返回到界面。这个例子用于解释流程,不是一次实测记录。

先认识这个项目,再看它怎么工作

本文用 pi-rust 的源码做例子。它是一个用 Rust 实现的编码助手,运行命令叫 rpi,能够接入模型,并提供读文件、执行命令等工具。

先分清模型和 Agent 程序:模型可以返回“请调用读文件工具”的结构化请求,但实际打开本地文件的是程序。工具执行完,程序把结果送回模型,模型才能继续处理。

也就是说,一次看似简单的回答,可能是这样跑出来的:

用户提出任务
  ↓
程序把任务和可用工具说明发给模型
  ↓
模型返回工具名称和参数
  ↓
程序执行工具,获得文件内容
  ↓
程序把工具结果再发给模型
  ↓
模型根据文件内容回答

这里没有要求模型一次就把全部事情想完。程序反复做“请求模型 → 检查结果 → 执行工具 → 带着结果继续请求”,这段循环就叫 Agent Loop。

本文从普通用户输入出发,我们会走完核心运行流程,再解释压缩、重试、取消和恢复怎样接回这条主线。不逐行展开每个供应商的 HTTP 实现,但涉及“谁负责、什么时候发生、会改变什么”的关键关系都会讲清。

先分清三个尺度:会话、运行和 turn

“轮”这个字很容易让人误会:用户说了一句话,模型却可能被请求好几次。在本文里,三个尺度分别是:

概念对应什么读文件例子
Session,会话保存消息、分支和运行记录的长期容器今天读配置,稍后继续讨论,都可以留在同一个会话里
Run,一次运行Harness 管理的一次执行,有开始、结束、取消和结果从接受“读配置”任务,到回答、失败或中止
Turn,执行轮次Loop 的一个处理周期:取得一条模型消息,处理其中的工具调用,再做轮末检查第一轮要求读文件;第二轮根据结果回答

因此,一次 run 可以包含多个 turn,一个 session 又可以包含多次 run。模型生成一条完整消息,也不等于整个任务已经结束。

消息还分不同角色:User 是用户的任务或补充要求,Assistant 是模型回复,ToolResult 是程序执行工具后的结果。ToolCall 则是模型消息里的结构化内容,不是用户说的普通文本。一条 Assistant 消息可以同时包含文字和多个工具调用。

所谓 Agent 的“记忆”,在本文这条实现路径中,首先指程序保存的会话记录,以及每次请求主动带上的消息。不能因为同一个模型被调用了两次,就假定它自然知道上次工具读到了什么。

一条消息要经过哪些地方?

先沿中间的编号向下看:文字从哪里交出去,任务又交给谁? 蓝色是输入与转交,黄色是运行准备,绿色是执行控制;节点文字和编号同时说明职责,不必只靠颜色判断。

%%{init: {"flowchart": {"nodeSpacing": 36, "rankSpacing": 36}}}%%
flowchart TB
    A["① Editor 输入框<br/>取出文字,触发提交回调"]
    B["② TUI 终端界面<br/>分流输入,提交普通任务"]
    C["③ LaneHandle 运行入口<br/>将任务转交给对应 runner"]
    D["④ Harness 运行管理层<br/>保存消息,准备上下文与配置"]
    E["⑤ Agent Loop<br/>请求模型,处理工具与停止条件"]
    P["Provider 模型接入层<br/>转换协议,收发模型响应"]
    T["本地工具<br/>执行读文件、命令等操作"]
    A --> B --> C --> D --> E
    E -->|请求模型时调用| P
    E -->|收到工具调用后执行| T
    classDef input fill:#e8f0fe,stroke:#3b6cb7,color:#183153
    classDef prep fill:#fff4d6,stroke:#ae7b17,color:#593c00
    classDef run fill:#e8f5ed,stroke:#38815b,color:#16452c
    class A,B,C input
    class D prep
    class E,P,T run

① → ⑤ 是普通输入进入执行循环的转交顺序。底部两条支路表示 Loop 使用的两类能力,不表示模型请求和工具执行同时发生。通常先取得模型回复,再检查有没有工具请求;工具结果会回到上下文,用于后续请求。具体往返顺序留到后面的闭环图和时序图展开。

源码给这些地方起了不同的名字:

本文里的说法源码名称先记住什么
输入框Editor编辑和提交文字
终端界面TUI,Terminal User Interface接收输入、显示运行过程
运行管理层AgentHarness保存会话、准备运行条件
运行入口接口AgentLane / LaneHandle界面通过它提交任务,不直接操作运行内部细节
执行循环Agent Loop串起模型请求和工具执行
模型接入层Provider适配不同模型供应商

不必把 Harness 翻译成一个复杂概念。它管理的是“这次运行”:准备输入和配置,调用 Loop,记录进度,再处理运行结果。Loop 管理的是“接下来做什么”:请求模型、执行工具、检查是否还要继续。

Lane 不仅是界面中的一个入口名称,还关联会话里的执行通道和当前分支。会话可以保留不同的消息路径,Harness 沿当前 lane 所指向的分支准备历史,而不是把整个会话中所有分支的消息混在一起。AgentLane 是界面访问这条通道的接口,LaneHandle 则把调用交给相应 runner;它们不是另一个 Loop。

名字多,是因为它们处理的事情不同。输入框不应该负责挑选模型,模型协议适配器也不应该负责清空输入框。下面顺着消息看这些边界。

第一步:Enter 只把文字交出去

入口在 crates/rpi-tui/src/editor.rs 的 Editor::submit。

它取出编辑器中的文本、记入输入历史、清理撤销状态,再调用注册好的提交回调。下面只摘出与消息流向有关的语句,省略了锁、历史和状态清理代码:

let text = self.get_expanded_text();

// 中间省略历史记录和编辑状态清理。
if let Some(callback) = cb.as_ref() {
    callback(&text);
}

self.clear();

get_expanded_text() 还会展开折叠的粘贴内容,避免把界面上的粘贴占位标记当成用户的真实文本。

到这里,“读一下配置文件”仍然只是一段字符串。输入框把它交给回调,清空自己,等待下一次输入。它还没有请求模型。

这也是排查“按 Enter 没反应”的第一个检查点:提交回调是否触发?别一上来就怀疑模型服务。

第二步:界面先判断这条输入要去哪里

提交回调在 crates/rpi-cli/src/interactive_tui.rs 的 editor.on_submit 里。

它不把所有输入都当成模型任务。例如,能识别的 / 命令走本地命令处理,! 开头的 shell 输入走命令执行路径。Agent 正在运行时,新输入则进入等待调整运行方向的队列。

本文跟的是另一条路径:Agent 空闲,用户提交一条普通文本。

按 Enter 不等于发给模型。 先看输入类别,再看 Agent 是否空闲。绿色节点是本文继续追踪的路径,灰色节点是其他去向。

%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 30}}}%%
flowchart TB
    A(["按下 Enter<br/>非空输入"])--> B{"界面识别输入类型"}
    B -->|/ 命令| C["本地处理"]
    B -->|! 输入| D["shell 执行"]
    B ==>|普通文本| E{"Agent 当前状态"}
    E -->|运行中| F["steering 队列<br/>等 Loop 检查点消费"]
    E ==>|空闲| G["尝试开始运行<br/>显示消息,发送 UserInput"]
    G ==> H(["本文主线:交给 Harness"])
    classDef other fill:#f1f3f5,stroke:#89939e,color:#35404b
    classDef main fill:#e8f5ed,stroke:#38815b,stroke-width:2px,color:#16452c
    class C,D,F other
    class G,H main

空输入会提前忽略,图中没有展开。粗箭头突出普通文本、空闲状态的路径;并发状态检查和错误处理仍以源码为准。

这条路径中,界面先显示用户消息,再发送内部通知。核心动作是:

add_user_message(&ctx_for_cb.chat, text);
push_history(&ctx_for_cb.state, text);

ctx_for_cb
    .tx
    .send(TuiMessage::UserInput(text.to_string()));

这段省略了重绘、滚动和发送失败处理,不能原样当成完整实现。

TuiMessage::UserInput 可以理解为“收到了一条普通用户输入”的通知。界面主循环拿到它之后,调用 run_prompt_streaming,再通过 lane 提交任务:

lane.prompt_text(prompt, images).await

参数里还有图片,说明这一入口不限于纯文本。我们的例子没有图片,先沿着文本路径读。

这里要分清两个动作:

消息显示在屏幕上:界面已经响应了你的输入
任务交给运行层:后续代码开始准备执行

屏幕上出现了用户气泡,不等于模型已经收到请求,也不等于会话已经成功保存。

第三步:运行层把字符串变成一条消息

界面拿到的是一个 AgentLane 接口。当前路径使用 LaneHandle,它会通过 runner() 把任务交给对应的 Harness 运行实现。这些代码都在 crates/rpi-harness/src/agent_harness.rs。

随后,AgentHarness::prompt_text 把文本包装成内部消息。没有图片时,核心转换是:

字符串
  ↓
UserContent::Text
  ↓
UserMessage(内容和时间)
  ↓
AgentMessage::User

原函数还处理图片内容;与后续运行连接的语句是:

let message = AgentMessage::User(UserMessage::new(content, now_ms()));
self.run_core(vec![message]).await

为什么不直接把字符串发给模型?因为 Agent 程序还需要区分用户消息、模型消息和工具结果,也要维护会话历史。先形成统一的内部消息,后面才能一起处理。

run_core 再进入 run_core_with_entry。准备工作很多,第一次读时先抓住这几件:

  1. 把用户消息保存到 session,也就是会话记录。
  2. 读取当前会话分支,准备后面要使用的消息上下文。
  3. 准备系统提示词、模型配置和可用工具。
  4. 构造 AgentContext,把消息和工具放进去。
  5. 准备请求模型的函数,再交给 Agent Loop。

准备好的 AgentContext 有三部分:

pub struct AgentContext {
    pub system_prompt: String,
    pub messages: Vec<AgentMessage>,
    pub tools: Vec<Arc<dyn AgentTool>>,
}

系统提示词约束任务和行为;messages 提供已有对话及执行结果;tools 持有程序能实际执行的工具对象。给模型的只是工具说明,不是这些 Rust 对象本身。

此外还有 AgentLoopConfig:它放模型选择、消息转换、工具执行模式、取消信号和各个检查点的回调。Context 主要回答“当前有哪些信息和能力”,Config 回答“怎样使用这些信息和能力”。

最后两个参数也有各自职责:stream_fn 是请求模型的函数,emitter 把运行事件发给界面和记录层。Loop 不必知道具体网络协议,也不用直接操作终端窗口。

这些入口合起来可以读成:

run_agent_loop(
    新增消息,
    已有上下文,
    运行规则,
    事件出口,
    模型请求函数,
)

函数签名虽然长,但没有五套互相竞争的消息系统。消息在哪、能力在哪、规则在哪、结果通知谁,都有明确位置。

一个很像 bug 的地方:为什么传给 Loop 的是空列表?

Harness 调用 Agent Loop 时,代码是:

run_agent_loop(
    Vec::new(),
    agent_context.clone(),
    config.clone(),
    Arc::clone(&emitter),
    Arc::clone(&stream_fn),
)
.await

Vec::new() 是空列表。用户刚刚输入了一条消息,为什么这里却什么也不传?

因为这里有两个不同的入口:

  • agent_context.messages:已经准备好的消息历史。
  • 第一个 prompts 参数:还需要在这次 Loop 开始时追加的新消息。

当前 Harness 路径已经保存了用户消息,并从会话构造出包含它的 context。如果再把同一条消息放进 prompts,Loop 会追加第二遍。

把正确做法和误传同一条消息的情况放在一起,就能看出区别。这里的 M 代表刚提交的用户消息,“历史”代表此前消息。

%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 30}}}%%
flowchart TB
    A["固定前提:context.messages 已有 M<br/>历史 + 用户消息 M"] --> B{"新增 prompts 放什么?"}
    B -->|当前做法:空列表| C["历史 + M + 空列表<br/>M 只出现 1 次"]
    B -->|误传:再放同一条 M| D["历史 + M + M<br/>输入被重复追加"]
    classDef normal fill:#e8f0fe,stroke:#3b6cb7,color:#183153
    classDef good fill:#e8f5ed,stroke:#38815b,stroke-width:2px,color:#16452c
    classDef bad fill:#fdecec,stroke:#bc4b4b,color:#652424
    class A,B normal
    class C good
    class D bad

这里比较的是消息合并结果,不是两种运行模式。空列表只清空了“新增”入口,没有清空已经准备好的 context。

用我们的例子看:

agent_context.messages 已经包含:
  ……此前的会话消息……
  用户:读一下配置文件,告诉我超时时间是多少

这次还要额外追加的 prompts:
  空

所以空列表不是丢了输入,而是表示“输入已经在上下文里,不需要再放一次”。这个结论只适用于本文追踪的 Harness 路径,不能推成“调用 Agent Loop 永远要传空列表”。

第四步:到这里,才准备真正请求模型

run_agent_loop 位于 crates/rpi-agent/src/agent_loop.rs。它把已有 context 和新增 prompts 合起来,然后进入 run_loop。

对于普通的新任务,没有一条等待恢复的模型消息,所以会调用 stream_assistant_response,取得新的模型回复。

在真正请求之前,程序还要做一次转换:

Agent 内部消息
  ↓ 可选的上下文处理
适合本次请求的消息
  ↓ convert_to_llm
模型接入层认识的消息格式
  ↓ 加上系统提示词、工具说明和请求选项
调用 stream_fn

convert_to_llm 是消息转换函数,stream_fn 是请求模型的函数。关键调用为:

let opts = config.to_stream_options(resolved_api_key);
let mut response = stream_fn(&config.model, &llm_context, &opts);

stream_fn 由 Harness 根据 Provider 实现准备。到这一层才进入实际模型请求路径,之前的输入框、lane 和 context 准备都还不等于请求已经发出。

工具说明也会放进请求上下文。它描述可用工具的名称、用途和参数结构,模型才能提出结构化的调用请求。模型不是靠一个普通字符串“我要读文件”就触发本地操作。

这里还有两道不同的适配:convert_to_llm 把 Agent 内部消息变成模型层通用的消息类型;Provider 再把这些通用消息变成供应商的实际请求。前者处理消息角色和内容,后者处理 API 格式、连接和响应解析。

当前 Harness 的 build_stream_fn 会按 model.provider 查找实现,合并请求选项,并应用 Provider 层的请求前回调。请求选项中的超时、密钥等也不等于对话内容。

因此,“已经生成上下文”与“模型请求已经发出”之间,仍有消息转换、工具 schema 提取、配置合并和 Provider 接入几个环节。

第五步:模型让程序读文件,不是模型自己读文件

假设模型的回复里包含一个读文件的工具调用。下面只表达结构,不代表 pi-rust 的完整请求格式:

工具名称:read
参数:path = config.toml

Agent Loop 检查模型消息的内容,提取 Content::ToolCall。源码不是通过搜索自然语言中的“读文件”三个字来判断:

let tool_calls: Vec<ToolCall> = message
    .content
    .iter()
    .filter_map(|c| match c {
        Content::ToolCall(tc) => Some(tc.clone()),
        _ => None,
    })
    .collect();

取得工具请求后,程序查找并执行对应工具,把结果变成工具消息,再追加到上下文:

for result in &tool_results {
    let am = AgentMessage::ToolResult(Box::new(result.clone()));
    current_context.messages.push(am.clone());
    new_messages.push(am);
}

此时模型下一次收到的内容,可以包含:

用户:读一下配置文件,告诉我超时时间是多少
模型:调用 read,参数为 config.toml
工具:文件内容里有 timeout = 30

有了工具结果,下一次模型请求才有材料回答“超时时间是 30”。如果文件不存在,返回的就是错误信息,模型可以据此继续处理。工具结果不会自动变成模型记忆,需要由程序追加到消息上下文并再次发送。

这就是 Agent Loop 最核心的闭环:

这张图重点看两处:模型提出调用,程序实际执行;执行结果必须回到下一次请求。

%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 30}}}%%
flowchart TB
    A[("消息上下文<br/>用户任务 + 已有记录")] --> B["① 请求模型<br/>发送消息和工具说明"]
    B --> C{"② 检查模型回复<br/>有没有 ToolCall?"}
    C ==>|有| D["③ 程序执行工具<br/>例如读取 config.toml"]
    D --> E["④ 获得工具结果<br/>文件内容或执行错误"]
    E --> F["⑤ 追加 ToolResult<br/>为下一次请求提供材料"]
    F ==>|带着新结果继续| A
    C -->|没有| G["检查停止条件<br/>和待处理消息"]
    G --> H(["结束,或按条件继续"])
    classDef data fill:#fff4d6,stroke:#ae7b17,color:#593c00
    classDef model fill:#e8f0fe,stroke:#3b6cb7,color:#183153
    classDef tool fill:#e8f5ed,stroke:#38815b,color:#16452c
    classDef other fill:#f1f3f5,stroke:#89939e,color:#35404b
    class A,E,F data
    class B,C model
    class D tool
    class G,H other

沿着粗箭头走一圈,就是工具调用闭环。黄色节点强调数据:工具结果不会直接“进入模型脑子”,需要先写入消息,再通过下一次请求发送。图中省略了强制终止、错误和恢复分支。

还有一个明确边界:如果模型因为输出长度限制而中断,工具参数可能不完整。当前源码在这种情况下会生成失败的工具结果,而不是执行这批可能被截断的调用。

第六步:工具请求怎样变成一次真实执行?

前面说“程序执行工具”,源码里这一步并不是直接把模型参数传给 shell。execute_tool_calls 会安排执行批次,单个调用还要经过准备、校验和收尾。

环节源码入口作用
找到工具prepare_tool_call按名称从当前可用工具中查找;不存在就生成错误结果
准备参数tool.prepare_arguments由工具整理输入参数
校验参数validate_tool_arguments检查参数是否符合工具 schema
执行前检查before_tool_call可拦截调用、给出原因或改写参数
实际执行tool.execute程序进行读文件、执行命令等操作,并可上报进度
执行后处理after_tool_call可覆盖结果内容、错误标志或终止提示
包装返回值create_tool_result_message带上调用 ID,把结果放回消息体系

调用 ID 很重要:假设模型同一条回复里请求读两个文件,工具结果必须能对应到各自的请求,不能只按“这是一段文件内容”判断。结果消息携带 tool_call_id,将两者联系起来。

AgentToolResult 的 content 是可送回模型的内容,details 则是结构化的日志或界面信息,二者不能自动画等号。is_error 在结果消息上表达失败;terminate 是控制提示,不是错误的同义词。

这里的参数校验也不是完整安全保障。具体权限、命令限制和是否允许副作用,仍要由工具实现及执行前检查负责。当前实现中,执行前回调改写参数后不会再做同一次 schema 校验,因此回调本身也是需要信任和审查的代码。

多个工具:完成顺序不等于历史顺序

一个模型回复可以要求执行多个工具。当前实现如果配置为串行,或者批次中某个已匹配工具要求串行,就走串行路径;否则走并行路径。

并行不是所有步骤都同时发生:参数准备按调用顺序进行,准备好的实际执行任务才并发运行。源码还刻意区分两种顺序:

模型提出:先 A,后 B
实际完成:B 比 A 快

工具结束事件:B → A(界面可先显示 B 完成)
工具结果消息:A → B(批次收齐后按原调用顺序组织)

这样,界面可以及时反馈完成状态,供下一次请求使用的消息又有稳定顺序。不能从“B 的完成提示先出现”推断整个会话历史也被改成 B 在前。

执行完后,工具的增量更新入口会被关闭。迟到的进度回调不应把一个已经结束的工具重新显示成运行中。

第七步:Loop 为什么有两层,什么时候真正结束?

看到工具结果重新请求模型,还只理解了闭环的一半。完整控制关系在 run_loop 里:内层处理工具和 steering,外层处理本来要结束时出现的 follow-up。

steering 是“调整当前任务”的队列消息,follow-up 是“当前工作告一段落后继续做”的队列消息。它们都是后续可能追加到上下文的消息,不是给正在生成中的模型回复打补丁。

源码的内层条件直接表达了这件事:

while has_more_tool_calls || !pending_messages.is_empty() {
    // 注入待处理消息,取得模型回复,处理工具,再做轮末检查。
}

下面这张图只解释两层循环的分工。终止回调和异常可以提前退出,稍后单独说明。

%%{init: {"flowchart": {"nodeSpacing": 28, "rankSpacing": 36}}}%%
flowchart TB
    A["开始本次运行"] --> B["内层:处理一个 turn"]
    B --> C{"仍需工具续接<br/>或有待注入消息?"}
    C -->|是| B
    C -->|否| D["外层:检查 follow-up"]
    D --> E{"有追加任务?"}
    E -->|有:准备待注入消息| B
    E -->|没有| F(["本次 Loop 正常结束"])
    classDef cycle fill:#e8f0fe,stroke:#3b6cb7,color:#183153
    classDef queue fill:#fff4d6,stroke:#ae7b17,color:#593c00
    class B,C cycle
    class D,E queue

回到读配置的例子:第一轮收到 read,执行后有结果要续接,所以内层继续;第二轮给出回答,没有工具,也没有 steering,内层退出;外层再检查 follow-up。只有它也为空,正常路径才结束。

一个 turn 内部的真实检查顺序

在普通路径中,每个 turn 的主要动作是:

  1. 把待处理消息加入 context 和本次新增消息列表。
  2. 请求模型,取得一条最终 Assistant 消息。
  3. 如果模型消息标记为错误或取消,发出结束事件并退出。
  4. 收集工具调用,执行或生成失败结果,把结果追加到 context。
  5. 调用 after_tool_results,允许在工具结果追加后调整上下文。
  6. 发出 TurnEnd,调用 prepare_next_turn,再检查 should_stop_after_turn。
  7. 取出 steering;结合取消状态及内层条件,决定是否再做一个 turn。
  8. 内层退出后检查 follow-up;有则再进入内层,没有则结束。

这里的 hook 就是程序在指定检查点调用的回调。它不是什么隐藏的智能体,而是可插入规则的函数。它的调用位置决定了规则生效的时机。

哪些情况会结束,哪些情况只是错误结果?

情况当前源码行为不能误读成什么
模型消息的 StopReason::Error 或 Aborted结束本次 Loop,交给 Harness 处理后续结果或重试不是每一种工具错误都走这条路径
普通回复没有工具调用没有工具续接,但仍可能被 steering 或 follow-up 带入下一轮不代表一定立即结束
工具查找、参数校验或执行失败通常包装为错误工具结果,模型可在下一次请求处理不保证自动成功,也不必然立刻结束整个 run
工具批次中的所有最终结果都标记 terminate不再因为这批工具继续内层循环不是任意一个结果标记就硬停;队列和其他检查仍有自己的规则
should_stop_after_turn 返回 true直接结束 Loop,不再消费后面的队列是程序规则停止,不证明任务做完
取消信号已触发在检查点、Provider 或工具观察信号后走中止路径不会撤销已经写文件或执行命令的副作用

StopReason 描述模型消息为什么结束,TurnEnd 描述执行轮次结束,AgentEnd 描述 Loop 结束,Harness 的 RunEnd 描述运行管理层处理到结束。它们不是四种“任务一定完成”的同义词。

当前 Harness 可以通过 RPI_MAX_TURNS_PER_RUN 配置轮次上限,未启用时不会默认安装这个停止回调。达到上限会记录说明。因此,“正常退出”也可能是预算规则截断,不能只凭一个 Completed 状态证明答案正确或目标已达成。

屏幕上的文字为什么会一点点出现?

前面为了看清主线,把“取得模型回复”画成了一步。实际调用中,回复是流式到达的:先开始,再收到多次文本或工具调用增量,最后结束。

这里有两类事件:

  • AssistantMessageEvent:模型接入层收到的响应过程,例如开始、文本增量、完成。
  • AgentEvent:Agent 对外通知运行过程,例如消息开始、消息更新、工具执行结束。

Agent Loop 把前一类事件转换成后一类,终端界面订阅运行事件来更新显示。界面不需要自己理解每一家模型服务的协议。

可以把一次输出想成同一个显示框不断刷新:

MessageStart:先出现一个待填写的回答框
MessageUpdate:用当前回复快照更新这个框
MessageEnd:确认最终内容

这不是每来一个 token 就新建一条历史消息。

还有一处源码细节:收到 Start 时,Loop 先把部分消息放入 context;增量到达时,它向界面发送更新快照,但当前实现不会每次都重写 context.messages 中那个位置。等最终消息到达,再替换占位消息。不能把“屏幕一直更新”理解为“消息上下文也每次同步重写”。

回答显示了,会话也就保存了吗?

不一定。至少要区分四种东西:

数据供谁使用是否等同于完整会话历史
current_context.messagesLoop 准备后续请求不是;它是当前运行中的工作上下文,可被压缩或替换
new_messages向调用者返回本次新增内容,参与保存不是;它不包含完整历史
运行事件与回复快照界面、记录层等订阅者不是;同一条消息可产生很多次更新
Session 中的消息条目与运行记录后续运行、分支选择、恢复是持久化依据,但消息条目和进度记录也有区别

工具结果进入 context,是让下一次模型请求能用它;同时进入 new_messages,是让调用者知道本次产生了什么。两次 push 有不同用途,并不代表供模型读取的同一列表里出现了两份。

用户消息在 Loop 开始前保存。运行期间,FrameRecordingEmitter 将流式进度写成记录;SettlingEmitter 还会在合适的消息完成点提交需要提前保存的内容,例如含工具调用的模型消息,使工具开始记录能引用到它。运行结束后,Harness 保存尚未提交的新增消息,避免重复提交,并处理结束状态。

进度帧不作为普通消息条目直接进入分支历史,否则一个回答的几十次增量会变成几十条对话。正常完成并保存消息后,相关帧会被标记清理;异常中断时,已提交的帧则为恢复提供依据。

因此,界面显示、上下文更新、消息完成和持久化成功是不同观察点。屏幕出现一个回答框,不能证明最终消息已经成功保存。

这也解释了普通用户消息为什么在提交回调里先显示:Harness 路径传给 Loop 的新增 prompts 为空,不会重复发出这条用户消息的新增事件。运行中排队的输入则在被消费时走消息事件路径。

上下文越来越长,压缩发生在哪里?

每次工具结果和模型回复都会扩充消息。读几份文件以后,文件内容可能比原始任务长得多。模型有上下文窗口,程序就需要决定哪些旧内容以原文保留、哪些用摘要代表。

这个过程叫 compaction,也就是上下文压缩。它与 convert_to_llm 的职责不同:前者控制下一次请求要保留什么信息,后者把信息转成模型层支持的类型。

当前 Harness 在两个时机接入压缩:

  • 运行开始前:估算当前分支的消息长度,启用压缩且达到阈值时,准备摘要和保留尾部,并保存压缩条目,再重建上下文。
  • 工具结果追加后:通过 after_tool_results 检查增长后的上下文,必要时保存压缩条目,并把 Loop 工作上下文替换成摘要和保留尾部。

这意味着“下一次模型收到的消息”不一定等于“会话里所有原始消息的逐条复制”。分支条目、上下文投影与压缩一起决定请求内容。前面的空 prompts 解释仍然成立:输入已经纳入上下文构建路径,不该再重复追加;发生压缩后,它可能通过保留消息或摘要参与,而不保证永远保持原文形态。

摘要也不是无损编码。判断 Agent 为什么遗漏某个细节时,除了检查输入是否保存,还要检查上下文构建、压缩和请求前转换有没有改变可见信息。这里不展开摘要算法,但压缩的接入位置和对循环的影响不能省略。

运行到一半,用户又说了一句话怎么办?

例如读文件时,你补了一句“不要修改任何内容,只告诉我结果”。

当前 TUI 对运行中的普通输入调用 lane.steer,把它放进 steering queue。这里的 steering 就是“调整当前任务方向”的消息队列。

它不会让模型在已经进行的请求中途立刻改写回复。Loop 在检查点取出队列消息,加入上下文,在后续模型请求中使用它。工具正在执行时收到的输入,也要等执行过程到达相应检查点。

队列有三种用途,区别主要在消费时机:

队列什么时候消费适合表达什么
steeringLoop 启动时,以及工具批次处理和轮末检查之后“当前任务换个方向”
follow-up内层循环本来要停下来时“当前工作告一段落,再做这件事”
next-runHarness 开始新 run 时,在调用者的新输入之前加入“下一次开始运行时,先带上这条要求”

队列配置还决定一次取出全部消息,还是只取最旧一条。因此,排队时间、消费时间和模型真正看到输入的时间不是同一刻。

例如工具已经开始修改文件,此时输入“不要修改”并不等于可靠地阻止这次修改。steering 只会影响检查点后的请求;需要中止执行时,应走取消机制,而取消也不是回滚机制。

请求失败了:重试、取消和恢复不是一回事

重试:再次尝试,不是正常的工具续接

正常工具续接会把上一轮工具结果加入 context,再请求模型;retry 则是在失败策略允许的情况下再次尝试。不能把两者都理解成“再调用一次模型”。

当前 Harness 在 Loop 外包了一层重试逻辑。它检查返回消息中最后的模型消息,只有被识别为可重试的模型错误、策略开启且预算未耗尽时,才安排下一次尝试。等待时间按指数增长并受上限约束,还会写入 retry_pending 记录并发出 RetryScheduled 事件。等待期间取消可以结束等待。

这也说明:Loop 返回 Rust 的 Ok(new_messages) 不代表模型一定成功。错误可以装在 Assistant 消息的停止原因中,由 Harness 再判定;直接返回的 AgentError 则走另一条错误处理路径。

每次尝试在当前调用处使用准备好的 agent_context.clone(),不是把失败尝试里所有临时消息直接续上。当前实现还区分 Agent 层的重试预算和 Provider/SDK 的请求重试预算,不能从一个参数推断两层行为完全相同。

“重试”尤其不能被当成工具执行的 exactly-once 保证。如果涉及写文件、支付或外部命令,必须审查副作用与持久化边界,不能因为它叫 retry 就认为重复执行安全。

取消:传递停止信号,不是撤销已发生的事

AgentLoopConfig.signal 将取消信号交给模型请求,也为工具生成子 token。取消被实际观察到之后,执行才进入中止路径;具体响应速度还取决于 Provider 和工具实现。

已经完成的外部动作不会因为 token 被取消而倒放。当前 Loop 还在工具批次结束后处理 steering,再检查取消,以免执行期间排队的消息无故滞留。停止信号的处理不是简单地跳出所有代码、不再记录任何内容。

恢复:先判断已经发生了什么,再选择入口

进程退出以后,只剩持久化记录,内存里的 context 和队列状态不能直接拿回来。恢复逻辑要检查已有消息、运行进度和工具记录,区分“结果已保存”与“实际效果未知”。

如果一个工具开始执行但结果没有成功记录,不能断言它没有执行。当前恢复路径会识别未解决的调用,并按记录与工具重放策略处理;无法确定的外部结果会用明确的中断说明表达,而不是编造成功。

底层 Loop 有三个入口,分别适合不同的最后消息状态:

入口解决什么情况第一步做什么
run_agent_loop已有 context,还可能有新增输入合并 prompts 与已有消息,进入循环
run_agent_loop_continue没有新增 prompt,从已有上下文续接;要求非空且最后不是 Assistant请求新的模型回复
run_agent_loop_from_assistant最后已有 Assistant 消息,其中工具尚待处理先处理这条消息的工具,不重复请求或追加它

第三种入口很重要:如果模型已经给了工具请求,不能跳过未完成的调用结果直接问下一轮。当前 Harness 的 from-assistant 路径不做同一层自动重试,以免一进入就再次执行那条消息的工具。

恢复不保证原任务一定成功,更不保证所有工具都可安全重放。它首先要保存可确认的进度,让下一步在一份可解释的历史上继续。

把一条消息的旅程连起来

回到我们的例子,整条路径现在可以读成:

最后这张时序图只保留四个角色,分成三个阶段。模型在右侧,读文件发生在 Agent 程序内部,两者不要混为一谈。Harness 和 Loop 都属于 rpi 的运行部分,这里合并为“Agent 运行层”;用户操作也合并到界面栏。

%%{init: {"sequence": {"messageMargin": 24, "noteMargin": 8, "mirrorActors": false, "actorMargin": 35}}}%%
sequenceDiagram
    autonumber
    participant UI as 终端界面
    participant R as Agent 运行层
    participant T as 本地工具
    participant M as 模型服务

    rect rgb(232, 240, 254)
        Note over UI,M: 阶段一:接住输入,准备任务
        UI->>UI: 用户按 Enter,显示输入
        UI->>R: 提交普通任务
        R->>R: 保存用户消息,准备上下文和工具
    end

    rect rgb(232, 245, 237)
        Note over UI,M: 阶段二:请求模型,执行工具
        R->>M: 发送任务和工具说明
        M-->>R: 返回 read 的名称和参数
        R->>T: 执行读文件
        T-->>R: 返回文件内容
        R->>R: 把工具结果加入上下文
    end

    rect rgb(255, 244, 214)
        Note over UI,M: 阶段三:带着结果再次请求,给出回答
        R->>M: 发送包含文件内容的消息
        M-->>R: 返回回答增量和最终消息
        R-->>UI: 更新回答显示
        R->>R: 处理保存和运行结束状态
    end

箭头编号帮助定位先后,背景色划分阶段。图中为突出任务往返,省略了第二阶段的界面通知,也把多次增量更新合并成一次箭头。运行期间同样有进度记录,并非到最后才开始保存。这里只展示示例任务的正常路径,不是完整并发时序。

名字不需要一次背下来。把整篇压缩成三个检查问题:

  1. 模型这次看到了什么? 查当前分支、context、压缩和消息转换,不只看屏幕。
  2. 程序这次实际做了什么? 查 ToolCall、参数检查、执行事件和 ToolResult,不只看模型说法。
  3. 为什么继续或停止? 查工具续接、队列、停止回调、取消和 Harness 结果,不只看最后一句回答。

如果这三件事能从源码里对上,一条消息的输入、执行、反馈和保存就连成了完整系统。模型提供下一步内容和工具请求,程序掌握实际执行与运行控制;会话记录则让后续请求和恢复有据可依。

下一步打开哪些文件?

以下路径相对于 pi-rust 仓库根目录。链接固定到本次核对的提交,而不是随时变化的 main。

想回答的问题文件与入口
Enter 如何提交文字?crates/rpi-tui/src/editor.rs,搜索 fn submit
输入如何分流?crates/rpi-cli/src/interactive_tui.rs,搜索 editor.on_submit、TuiMessage::UserInput
界面怎样提交任务?crates/rpi-cli/src/interactive_tui/run.rs,搜索 run_prompt_streaming
消息怎样保存、怎样准备 context?crates/rpi-harness/src/agent_harness.rs,搜索 prompt_text、run_core_with_entry、build_stream_fn
模型和工具怎样循环?crates/rpi-agent/src/agent_loop.rs,搜索 run_loop、stream_assistant_response、execute_tool_calls
Context、队列和工具结果是什么?crates/rpi-agent/src/types.rs,搜索 AgentContext、QueueMode、AgentToolResult
回调在哪些检查点生效?crates/rpi-agent/src/hooks.rs,搜索 AgentLoopConfig
流式进度怎样保存和恢复?crates/rpi-harness/src/frame_progress.rs,搜索 FrameRecordingEmitter、salvage_run_frames
哪些消息会提前保存?crates/rpi-harness/src/settle.rs,搜索 SettlingEmitter、needs_early_commit
压缩和重试在哪里接入?agent_harness.rs 中搜索 after_tool_results、retry_attempt、resume_pending;压缩细节见 compaction.rs

如果想验证自己是否看懂,可以在测试环境只记录函数名、消息类型和消息数量,不打印密钥或完整敏感输入。观察普通用户消息何时加入 context,工具结果何时被追加,以及下一次模型请求之前消息列表发生了什么变化。

如果你要改“按 Enter 之后的行为”,先找提交回调;要改“模型收到的上下文”,看 Harness 和消息转换;要改“工具执行完后是否继续”,看 Loop。先拿住一个具体问题,再选入口,比从仓库第一行开始读有效。