Agent设计--架构篇

Agent架构设计

本文主要来分享本人在学习 Agent 架构设计的过程,本次学习过程基于一个从零开始的终端编码 Agent 的整体架构设计。在之前几篇文章中,我们已经走完了 RAG 检索这条支线;本文回到主干,来聊整个系统是怎么立起来的。

简单入门

在讨论架构之前,我们需要先对齐一个基本问题:一个 Agent 到底是什么?

如果去问任何一个写过 LLM 应用的人,得到的答案大概率是:一个循环。模型收到消息,可能回文本,也可能要求调用工具;如果调用了工具,就把工具结果喂回去,再问一次模型;直到模型不再要工具,达到一个多方约定好的停止点,一轮结束。把它写成伪代码,大概就是:

1
2
3
4
5
6
7
while (true) {
response = llm(messages);
if (response.tool_calls.empty()) break;
for (call : response.tool_calls) {
messages.push(run_tool(call));
}
}

这个描述是否是正确的?其确实是一个无可辩驳的答案,这种 ReAct 模型就是几乎所有 Agent的核心循环。但是,如果我们真正开始动手想要把 Agent 实现出来,很快就会发现,这个循环根本装不下一个「能用」的 Agent。因为在这个循环的每一个缝隙里,都塞满了各种循环本身没有帮你解决的东西:

  • 模型的回复是流式到达的。第一个 token 出现时,其他的可能还在网络上,用户期望立刻看到输出(首字延迟低),而不是等几十秒后一次性刷新
  • 一次工具执行可能很慢(一次 RAG 文档检索建库可能要四十多秒)。在工具执行期间,界面要不要卡死?用户能不能继续输入?
  • 部分工具会产生真实世界的影响,就比如一个 write 工具可能写文件,一个 net 工具可能发网络请求。但是模型说写就能写吗?谁来允许工具的执行?
  • 模型一次可能输出多次工具调用。此时要并发跑还是串行跑?出错了怎么办?
  • 用户可能在模型输出期间输入 Ctrl-C,也可能拒绝工具的执行,网络场景下任何请求都可能失败等等,这些要怎么处理?

上面种种问题,究其核心可以浓缩为一个状态问题:系统现在在哪?在等什么?接下来允许发生什么?在错误的时候应该做什么等等。而裸循环把这些状态全部隐式地编码到了调用栈里面——while 走到哪一行,系统就在哪个状态。这在单线程同步的世界里勉强成立,但一旦引入流式、后台执行和用户审批等场景,此时简单的调用栈就再也无法清晰的描述系统了。

因此,本文的核心问题其实只有一个:如何把一个「本质上是循环」的系统,组织成一个可以被推理、被测试、被并发访问的架构?

我们的答案是:把状态从调用栈中搬出去,成为被显式建模的数据;把所有会碰外部世界的东西,从核心里赶出去。也就是所谓的纯函数核心 + 副作用外置。这个方案并非凭空拍板,整体形态主要借鉴了 agentty [1](https://agentty.org/blog/cpp-design-of-a-terminal-agent/) 中关于「如何用 C++ 写一个终端 Agent」的讨论。后文的所有设计,几乎都是这一个决定的推论。

第一个问题:状态和副作用纠缠在一起会发生什么

要理解为什么需要「纯核心」,最好的方式是先看反例。假设我们不做任何架构设计,直接在裸循环上加需求:流式输出、后台工具、权限审批。最直觉的写法大概会是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反例:一切纠缠在一起的"上帝循环"
while (running) {
auto response = stream_llm(messages, /*on_delta=*/[](std::string s) {
print(s); // 渲染逻辑内联在传输回调里
});
for (auto& call : response.tool_calls) {
if (needs_permission(call)) {
auto ok = popup_yes_no(call); // UI 阻塞在核心循环里
if (!ok) continue;
}
auto result = pool.submit(run_tool, call).get(); // 工具阻塞在核心循环里
messages.push(result);
}
}

这段代码能跑,而且在很多 Agent 的教程示例中也是这个处理逻辑,但它的可测性是零:想测试「流式中途用户拒绝工具请求」这条路径,你必须起一个 LLM 服务、真实的渲染一次 UI、实际的运行一次工具。每一条分支都在物理上依赖外部世界。它的可推理性也是零:读代码的人无法回答「系统现在处于什么状态」——答案散落在多个 lambda 和回调逻辑中。

这里其实就可以指出我个人的一个观点了:副作用的可怕之处不在于它发生,而在于它和决策逻辑混在一起,使得任何一段逻辑都无法脱离环境被单独校验。状态机决定「该做什么」,环境负责「应该如何做」——一但这俩部分纠缠到一起,测试以及任何其他操作就只能将整体作为一个单元进行测试,再也难以将其拆分为一个个小单元单独处理,这种你中有我,我中有你的设计在任何时候都是需要相当警惕的。

那么这里自然的引出了一个解决方案:把「决策」从「执行」里剥离出来,让决策本身成为一个不碰任何外部世界的纯函数

纯核心:update(Model,Msg) → Step

沿着上面的结论,我们把系统重新表述为一个极简的循环:

1
Msg(发生了什么) → update(Model, Msg) → Step{ 新的 Model, 要执行的 Cmd }
  • Model 中储存了系统全部的当前状态,比如对话、阶段、权限档案等
  • Msg 是「已经发生的事情」。注意,在我们的设计中,Msg 不是命令,而是事实。用户提交了文本、网络连接中到达了一个 Token、工具返回了结果,这些都是事实
  • update 是纯函数,只接受旧状态和一个当前事实,输出一个新状态和一个「副作用请求」
  • Cmd 是副作用请求执行的内容,update 只负责描述它,不实际执行它

这就是 Elm 架构(TEA)的形态。在我们的初版实现中,这个接缝只有几十行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// runtime/agent.hpp
struct NoCommand{};
struct StartStream{ Request request; };
struct RunTool{ std::string id; std::string name; nlohmann::json args; };

using Cmd = std::variant<NoCommand,StartStream,RunTool>;

// 语义是 新的内部状态 + 需要执行的外部指令
struct Step{
Model model;
Cmd cmd;
};

// 根据一个 Msg 生成后继 Model 和需要执行的 Cmd。
// Model 按值传入:调用方可以复制旧快照,也可以通过 std::move()
// 转移状态所有权。update() 不保留对输入 Model 或 Msg 的引用。
[[nodiscard]]
Step update(Model model,Msg msg);

这里不对于内容进行赘述,自行来理解分析即可。值得停留的只有一件事:**update的函数签名就是整个架构的承诺**。它不接受任何上下文指针、不访问任何全局单例、不返回任何需要解释的句柄。给它相同的(Model,Msg),就能得到相同的 Step。外部网络是否出错、终端使用的是哪个版本、外部服务是否正常,都与这里的逻辑无关。

这个承诺的价值可以立刻在测试中体现。在我们的设计中,存在 agent_loop_test.cpptool_loop_test.cppprofile_test.cpp等测试。这些测试不需要网络、不需要终端、不需要真实的 LLM。而这正是因为被测对象是一个纯函数,往其中喂构造出来的 Msg 序列、断言产出的 Model 即可获得真实环境中真实输入会得到的结果。当前项目中加起来三百多个测试用例,核心状态机的行为是在毫秒级别跑完的。这在前文中反例架构中是不可想象的,在那种架构下,想要测一条分支,需要完整模拟全部的副作用环境(网络、服务、IO等等)。除此之外,还有一层价值隐藏在实现里。update 中的全部「调度逻辑」长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// runtime/agent.cpp —— update 的完整实现
Step update(Model model,Msg msg)
{
Cmd cmd = std::visit(
[&model](const auto& event) -> Cmd{
return apply(model,event);
},
msg
);
return Step{
.model = std::move(model),
.cmd = std::move(cmd),
};
}

系统中实际上使用 std::variant 来描述 Msg。不同种的 Msg 各自对应一个 apply 重载,std::visit 里没有任何 default 分支。这意味着「漏处理一种事件」在这套代码里不是运行期的日志,而是编译错误。新增一种 Msg,编译器会把每一个忘记接该类型的地方全部给暴露出来。借用 agentty 中的依据原话:The compiler is a proof that you handled every event(编译器就是你处理了每个事件的证明)。动态语言里需要不知何时才可能爆出的 unhandled action type,在这里是一个被编译器强行约束并能够被轻松解决的 build error

还有一个容易被忽略的细节:Request 里有一段注释——系统提示词不是 update 构造的,而是宿主在 effect 侧填充。因为构建系统提示词需要读文件(记忆、skill 目录等),而 update 必须保持纯洁。这种约束也能一定程度上证明这套架构的纪律性:即使只是「填一个字符串」这种事,只要它碰 IO,就属于宿主的职责。后文会看到这个决定的完整推论。

状态:让非法状态不可表示

现在我们应该已经理解以 update 为主的核心的纯洁性了。接下来的问题是:Model 应该如何来建模一个系统的状态?最直觉的做法是其中包含一组布尔标志:

1
2
3
4
5
6
7
// 反例:布尔标志的排列组合
struct Model {
bool is_streaming;
bool is_waiting_permission;
bool is_executing_tool;
// ...
};

这个写法的问题在于:它允许大量物理上合法,语义上荒谬的状态:三个 bool 同时为 true 是什么意思?is_waiting_premissionis_executing_tool 同时成立时,这能够说明模型在等待工具审批的同时又在跑工具吗?系统的每一时刻都只可能处于一种局面,这种「互斥」是领域事实,而这种多个独立 flag 难以实现这种强约束,要想实现只能依赖编写人员的自觉,而这是不可接受的。

C++ 里表达「有且只有一个」的现成工具是 std::varient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// runtime/model.hpp
struct Idle{ };
struct Streaming{ };
struct AwaitingPermission{ };
struct ExecutingTool{ std::string id; };

// 将多个阶段聚合。这样即能够保证一个状态的原子性以及独占性,也能方便管理
using Phase = std::variant<Idle,Streaming,AwaitingPermission,ExecutingTool>;

struct Model{
Phase phase{Idle{}};
Thread thread{};
Profile profile{Profile::Write};
std::optional<PendingPermission> pending_permission{};
};

这里正体现了一句我很喜欢的话:make illegal states unrepresentable(让非法状态不可表示)。这个说法出自 Jane Street 的 Yaron Minsky 4,agentty 的设计也反复引用它。这里的代码实际上明确表达了一个事实:系统要么在等流式输出、要么在执行工具、要么在等审批、要么处于空闲。varient 的语义恰好就是「有且只有一个」,编译器替你排除掉了其余的所有组合。而且注意 ExecutingTool 实际上会携带工具调用的 id,这实际上想要表达是状态本身会携带自己的证据。它不是「正在执行工具」这个孤立事实,而是「正在执行本次调用」,这为后续工具执行完毕后产出的 ToolExecOutput 消息回归 owner 线程时的对账提供了依据。

对于这里,我其实不禁感慨。之前在大一的时候,看 NJU 的 PA 的时候首次接触到状态机的概念,在大二阅读计网黑皮书的时候,再次深化自己对于 FSM 的认知。直到现在,我很多时候才真正将状态机的思维给融合到设计中来。就比如这里,其中的 Phase 本身其实也是一个状态机。其中的每个状态实际上包含了丰富的语义,就比如 Idle 代表着当前系统没有需要处理的工作,正处于空闲,对应着没有待审批/执行的工具,没有待处理的请求等等。而对应的 Streaming 则代表当前系统正在处理一次流式调用等等。每一个简单的字段,其实际的取值都浓缩了系统其他地方所发生的事件。而我们能够基于这个事件来协调当前的工作。这正是我个人所认为状态机最核心的一点。

沿着这个设计,还有一个值得说明的涌现现象:工具调用的串行化是免费的update 在一次流转中至多产出一个 RunTool,而 ExecutingTool 作为一个互斥的 Phase,天然构成了一个门闩:工具没执行完,状态机就不在能受理新派发的场面里面。我们没有写任何「工具调度器」、没有配置任何并发上限参数,「一次只跑一个工具」是状态表示方法的直接推论。当然,串行好坏与否是可以讨论的,甚至于,串行本身是否要优化为并发也是可以讨论的,但这里的重点是:这里的串行执行的行为是设计出来的、显示的,并不是调度细节的偶然产物。想改成并行,需要首先做修改是 Phase 的表达(比如ExecutingTools{ std::vector<...> }),而不是在回调迷宫里加锁。

对于事件也同理,Msg 也是一个 varient,如下:

1
2
3
// runtime/msg.hpp
using Msg = std::variant<Submit,StreamTextDelta,StreamToolCall,StreamFinished,
StreamError,ToolExecOutput,SetProfile,PermissionApprove,PermissionReject>;

值得注意的是这里的输入是对称的:用户的提交(Submit)、网络的事件(StreamTextDelta)、工具的结果(ToolExecOutput)、用户的审批(PermissionApprove)——在状态机眼里全部是同一种东西:一个事实。系统不需要区分「谁说的事」,只需要回答「这个事实发生之后,我在哪个状态、接下来请求什么」。这种统一在后面会反复兑现价值:换掉前端(TUI 换成 headless)、换掉传输(Ollama 换成 Anthropic),状态机一个字都不用动。

宿主:副作用得有个家

讲到这里,现在的局面是:系统的核心是纯的。但是一个网络请求副作用(StartStream)要发 HTTP 请求、一次 RunTool 执行要读取文件。核心本身并不复杂执行这些动作,但总得有人真的去做这些事。这个角色在当前系统中我们称之为宿主(AsyncHost)。宿主所需要承担的工作可以列成一份很短的清单:

  • 维护一个 inbox:所有异步来源(网络事件、工具结果、用户输入)都往该结构中投递 Msg
  • drain 驱动:owner 线程需要按 FIFO 的顺序来消费 inbox 中的 Msg 事实,将其逐个喂给 update 并接收产出的 Cmd
  • 执行 Cmd 的副作用:负责替 owner 线程来真正做出产出
  • 判断静止(quiescent):基于当前已有信息判断当前回合是否可以停止

其中最后一点值得展开聊一聊,因为它定义了整个系统的「回合」边界:

1
2
3
4
// Owner-thread only. 阻塞事件循环:drain → 判定静止 → 等唤醒。
// 在没有 in-flight worker 可等时返回,也就是 phase 已经是 Idle(回合结束)
// 或 AwaitingPermission(等的是 owner 自己的审批输入)。
void run_until_quiescent();

静止不是「没消息了」,而是「没有在途的工作可等了」。流式调用中 inbox 空了,不代表静止,此时网络上可能还有在途的 token,此时的状态被称之为 Streaming,宿主应该继续等待。如果工具在后台跑,此时也不能够被称之为静止,毕竟后台线程中还存在在途的结果。在当前设计中,真正的静止点只有两个:Idle(回合自动结束)和 AwaitingPermission(等待用户审批一次工具请求)。这个定义让「一个回合」成为了一个有明确起止的单元——线程可以基于当前是否停止的状态来明确当前自己可以来执行什么操作。

为什么需要 wake_fd

在当前设计中,run_until_quiescent 是阻塞式的,这带来一个尖锐的问题:流式输出进行中(宿主阻塞着等待 token),UI 如何才能够继续读取键盘?用户想按 Ctrl-C、想继续打字,而 owner 线程正卡在「等在途工作」中。

当前项目的解法是把「后台有新事件」变成一个可 poll 的文件描述符

1
2
3
4
5
// 后台唤醒的可 poll 读端。前端把它和 stdin、SIGWINCH 放进同一个 poll,
// 从而在流式输出进行中也能读键盘 —— 这是 run_until_quiescent 做不到的事,
// 那个函数在 phase 是 Streaming 时不返回。
[[nodiscard]]
int wake_fd() const noexcept;

在 owner 线程上的 UI 循环中,我们把三个来源:stdin(键盘)、wake_fd(后台事件)、SIGWINCH(窗口变化)给放进了同一个 poll,谁就绪处理谁。于是「渲染」和「驱动状态机」在 owner 线程上合流,却互不阻塞。这个设计还有一个降级出口:唤醒机制不可用时退化为惰性哨兵 + 超时轮询(wake_fd 返回 -1),系统仍然正确,只是动画糙一点。正确性不依赖最优雅的机制——这在 RAG 的降级设计中也多次出现,几乎是这个项目的一个母题。

两种执行环境:共享池与隔离通道

在当前设计中,宿主手里有两种后台执行资源,分工不同:

1
2
provider 流 ──→ 共享池(WorkPool)      高频、短小、I/O 密集
工具执行 ──→ 隔离通道(detach) 低频、可能很重、可能卡死

为什么分开?考虑一个具体场景:search_docs 工具首次调用要建库,四十多秒。如果工具和 provider 流共用一组 worker,一个重工具就能把池占满,正在流式输出的其他回合全部饿死。流是系统的呼吸,工具是系统的动作;动作可以慢,呼吸不能停。 所以流走共享池(轻、多、要公平),工具走隔离通道(重、可以随便卡,卡死了也只是那一个工具超时)。

源码里还有一条与线程模型相关的注释,很能体现这类系统的工程细节:

1
2
// shared_ptr 而非成员对象:隔离通道的线程是 detach 的,可能活得比 host 久。
// 共享所有权让它们不会对已析构的 WakeSignal / Inbox 动手。

后台线程比宿主活得久——这不是刁钻的边界情况,而是 detach 线程的日常。共享所有权(shared_ptr)延长生命周期让「谁先死」不再是一个需要祈祷的问题。

权限:谁替用户说「可以」

到目前为止,系统已经能流式输出、能跑工具了。但还有一个问题是裸循环完全没有回答的:模型要求写一个文件,凭什么写?

Agent 的权限问题和传统软件不同。传统软件的权限是「用户操作 → 系统执行」,操作本身就是用户意图。而 Agent 是「用户给了一个目标 → 模型自行决定一系列动作」——动作的意图是模型推断的,不是用户声明的。模型可能推断错。所以权限系统必须插在「模型的决定」和「动作的执行」之间,而这个缝隙恰好就是我们架构里现成的东西:update 决定 RunTool,宿主执行 RunTool——权限判定就长在这条缝上。

这套声明式方案同样来自 agentty 的设计(某种程度上 agentty 参考的是 claude code 的设计?):工具不决定自己的信任级别,只申报能力标签,判定由一条独立于任何工具的纯函数完成。设计是三个纯数据的乘积:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// tool/effects.hpp —— 工具声明自己会碰什么
enum class Effect : std::uint8_t {
ReadFs = 1u<<0,
WriteFs = 1u<<1,
Net = 1u<<2,
Exec = 1u<<3,
};

// tool/policy.hpp —— 纯函数:声明 × 用户意图 → 判定
[[nodiscard]]
constexpr Decision permission(EffectSet effects,Profile profile) noexcept
{
if (profile == Profile::Write){
return Decision::Allow;
}
if(effects.has(Effect::WriteFs)||effects.has(Effect::Net)){
return Decision::Prompt;
}
if(effects.has(Effect::Exec)){
return Decision::Prompt;
}
if (profile == Profile::Minimal && effects.has(Effect::ReadFs)){
return Decision::Prompt;
}
return Decision::Allow;
}
  • 每个工具在注册时自带 EffectSet(读文件?写文件?网络?执行?)——这依赖于工具作者的诚实申报
  • Profile(minimal / ask / write)是用户的事前意图,一个环境变量就能切
  • permission() 是两者的纯函数映射,输出只有两种:AllowPrompt

这个设计的全部力量在于每个因子都可以独立演化:新增一个工具,申报它的 effects 即可,权限逻辑零改动;用户把 profile 从 write 切到 ask,所有工具的行为整体收紧,工具零改动。对比一下命令式的权限(在每个工具的实现里写 if (need_ask) ...),声明式把「工具是什么性质」和「当前政策是什么」彻底解耦了。

值得说明的是,教材在这条路上走得更远:它把 4 位 EffectSet(16 种组合)× 3 个 Profile 的全部 48 个判定单元格做成了 constexpr 权限矩阵,并用 static_assert 对照一份独立重述的 spec,在编译期证明全表正确——用原文的话说,最危险的部分 cannot ship wrong。我们的六个工具、一条 if 链就能读完全部判定,还没到需要矩阵证明的规模;但这个对照指出了这条路的方向:当判定逻辑继续膨胀时,下一步不是更多 if,而是把表格本身变成编译期对象。

值得注意判定发生的位置:它在 update() 内部——状态机收到 StreamFinished、手里攥着工具调用时,当场查注册表做判定。放行,就产出 RunTool;要审,就把 phase 切到 AwaitingPermission 并填上 pending_permission 这个位置的选择不是随手的:权限判定是决策,决策属于状态机;而「弹窗问用户」是副作用,属于宿主和前端。判定和交互又一次被那条核心边界切开了。

于是一个回合里的权限流就变成了:

1
2
3
4
5
6
模型请求工具 → update:permission(effects, profile)
├─ Allow → RunTool → 隔离通道执行 → ToolExecOutput → 下一轮流
└─ Prompt → phase = AwaitingPermission(静止点)
→ 前端展示待审批调用(maya 两段投影)
→ PermissionApprove / PermissionReject(Msg)
→ 状态机继续

PermissionReject 甚至可以带一段 feedback 文本——拒绝不是简单地说「不行」,而是把拒绝的理由作为事实交回模型,让下一轮流知道「为什么不行、该换个什么方向」。权限从一道闸门,变成了对话的一部分。

接缝:让「换掉」成为局部决定

架构图上还剩最后一块拼图:依赖的方向和接缝的纯度。

整个系统九个模块,依赖严格朝下、无环:repl(装配)→ ui / runtime → tool / prompt → provider / rag → http → domain。但比「分层」更重要的是每个接缝上暴露什么。这里有两个例子,都值得在别的项目里复用。

第一个是 provider 的接缝。系统支持三家传输(ollama / anthropic / openai),但状态机看到的只有一个类型:

1
2
3
// provider/provider.hpp
using EventSink = std::function<void(Msg)>;
using StreamEffect = std::function<void(Request,EventSink)>;

一个 StreamEffect 是「给我一个请求和一个事件出口,我把事实流出去」。SSE 解析、各家 API 的方言、重试策略,全部藏在实现侧。状态机不关心你用哪家模型,正如它不关心终端是什么型号。

第二个是 RAG 的接缝。rag.hpp 是整个项目里设计语义注释最密集的一个头文件,它开头的接口纯净原则值得原文引用:

1
2
3
// 接口纯净原则(#41 拍板):本头文件只用 std —— http/nlohmann 是 rag 的
// 实现依赖,PRIVATE 链接、一个 include 都不泄漏到这里。rag 的消费者
// (测试、bench、#43 接线处)看到的接缝永远是纯类型。

「消费者看到的接缝永远是纯类型」——这意味着测试可以注入假 backend(一个普通函数),bench 可以不碰网络就复现度量口径,而 search_docs 工具的接线处拿到的类型和单元测试拿到的完全一样。HTTP 是 rag 的实现细节,不是它的脸面。

至于所有的具体选择——用哪家 provider、哪个模型、什么 profile——全部汇聚在一个文件里:main.cpp,所谓组合根(composition root)。装配只发生一次,发生在最顶上;下面每一层都只依赖抽象接缝。这也是为什么 headless 模式(headless_runner)和行式回退可以和 TUI 共享同一套 runtime:它们只是不同的前端,插在同一个 dispatch / run_until_quiescent / model 接口上。

一个回合的生命周期

至此所有部件都齐了。把一个完整回合串起来走一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户输入
→ Submit(Msg) 事实进 inbox
→ update → StartStream(Cmd) 状态机请求起流
→ 宿主在共享池起 StreamEffect provider 发 HTTP + SSE
→ StreamTextDelta × N(Msg) token 逐个回流
↳ wake_fd 唤醒 UI → 增量重绘 (owner 线程不阻塞)
→ StreamToolCall(Msg) 模型要工具
→ StreamFinished(Msg)→ update 查 registry:
├─ permission = Allow
│ → RunTool(Cmd)→ 隔离通道执行
│ → ToolExecOutput(Msg)→ 回到 StartStream(带结果)
└─ permission = Prompt
→ phase = AwaitingPermission(静止点,球在用户脚下)
→ PermissionApprove / Reject(Msg)→ 沿上/下分支继续
→ 某轮流无工具调用 → Idle 回合结束

这条链上值得最后强调一次的还是那个结构:所有横向的箭头(HTTP、文件、终端)都发生在宿主和边缘;状态机只经历纵向的 Msg → Model 变换。 架构图上它是一条链,测试里它是 std::vector<Msg> 的序列断言,生产里它是真的网络——三者共用同一个核心,这就是「纯核心 + 副作用外置」买到的东西。

RAG 挂进来的位置

最后用一小节交代前几篇文章的 RAG 在这张图里的位置,作为系列的合流。

RAG 在架构里不是一层,而是 tool 模块的一个工具后端:模型调 search_docs,工具实现内部做懒建库(FNV-1a 目录树指纹漂移门控——指纹没变连库都不重建)、混合检索、渲染。对状态机而言,它和 calculator 没有任何区别,都是一个可能耗时很久的 RunTool子系统不需要知道自己活在哪个架构里,它只需要把自己的接缝做干净——这也是前文 rag.hpp 接缝纯净原则的另一个动机:它是可以被整体搬走的。

至于检索内部的两路互补、RRF 融合、增量缓存的两层失效半径,前文已详述,此处不再重复。

总结

本文是我围绕 Agent 架构设计的一次实践性总结。一开始,我的目标只是把一个教程式的 Agent 循环跑起来,但随着流式、后台工具、权限审批这些真实需求一个个压上来,我发现裸循环的每一处「顺便」都在把系统往不可测试的方向拖。真正值得留下来的,不是某几个具体的类图,而是那个一路被逼出来的核心决定:把决策做成纯函数,把副作用赶到边缘。

回头看,整个架构其实没有一步是「先设计后实现」的。最初只有一个很具体的问题:一个循环,凭什么变成一个系统? 状态藏在调用栈里没法推理,就有了显式的 Model;布尔标志允许荒谬组合,就有了 variant 的 Phase;核心纯了没人执行副作用,就有了宿主;宿主阻塞了 UI 没法响应,就有了 wake_fd;工具会卡死共享池,就有了隔离通道;模型的动作不是用户的意图,就有了声明式权限;每换一家供应商就改核心,就有了纯接缝和组合根。最终,这些对不同压力的回应,组合成了一个可以逐层检验的系统。

Agent 不应该只是「一个会调工具的 while 循环」,更应该是「一台被事实驱动、被副作用环绕的状态机」。循环只是它的一个投影。

先让决策纯下来,再谈并发与集成;先让状态不可伪造,再谈流转与恢复;先让接缝只暴露值类型,再谈替换与扩展。

架构不是画出来的图,而是被真实压力一步步逼出来的形状。图只是事后对这条路径的压缩。

参考文献

[1] https://agentty.org/blog/cpp-design-of-a-terminal-agent/
[2] https://guide.elm-lang.org/architecture/
[3] https://www.youtube.com/watch?v=OOMx2BHHWtE
[4] https://blog.janestreet.com/effective-ml-revisited/

-------------本文结束 感谢阅读-------------