跳到主要内容

letta-code — 本课题摘录

读了哪几篇: 01-stateful-turn(有状态回合与批准环路)。 其余五篇本轮没读。

这一家把本课题"状态放在哪"这一问推到了另一头:不在循环里,也不在进程里,在服务端。

它对本课题回答了什么

决定四(架构):状态在服务端,每次只发增量

维度常见做法这一家
谁存历史客户端进程内存服务端会话
每次请求发什么全量历史只发本轮新增的那一条
进程被杀之后历史丢失,只能靠本地文件重放服务端仍持有,重开即接上
未决的工具调用丢失服务端仍停在"需要批准"

(依据:Agent 库 · Letta Code · 一次回合是怎么跑的:后端抽象与 approval 环路 —— conversation 的消息历史、系统提示、未决工具调用全部由服务端持有,CLI 每次只发本轮新增的 message 或 approval 而非全量历史;进程被 kill 后服务端仍停在 requires_approval)

本课题读到这里,"状态在哪"已经有四种答案:

  • 循环自己拿着(最朴素);
  • 上一层拿着,循环无状态(kimi-code、pi);
  • 写进消息里,决策函数纯读(agentscope);
  • 服务端拿着,客户端只是调试器(letta-code)。

第四种是唯一能做到"换一台机器接着跑"的。

它自己的直觉句很好:把会话当成一台停在断点上的虚拟机。客户端不是虚拟机本身,而是调试器——attach 上去、看寄存器、执行一条外部指令、再把结果写回去让它继续跑。调试器崩了,虚拟机还停在原来那个断点上。

"需要批准"就是那个断点。

决定三:工具在客户端跑,结果当成一条新消息发回去

一个回合的形状:

用户敲回车
→ 组请求体(只发增量)→ 开流
→ 看这次为什么停:
正常结束 → 回合结束
需要批准 → 分三堆:自动放行 / 自动拒 / 问用户
出错 → 重试或报错
→ 本地执行工具
↺ 把工具结果当成一条新消息再发一次

注意这条回边的性质:它不是"循环再转一圈",而是"再发起一次完整的请求"。 一个用户回合被拆成 N 次服务端往返。

对本课题的意义:"循环"这个词在这里名不副实。 真正在转的是"客户端和服务端之间的往返",循环体在服务端。 这跟 qwen-code 的"核心里根本没有 while 循环"是同一族的观察。

"停在需要批准"的瞬间,服务端的回合已经结束了。 按中断键关掉终端、重新打开,那条待批准的调用还在,确认框会重新弹出来。

这不是客户端把它存到了本地文件,而是它本来就在服务端。

这一条对"怎么做人机协同"很有说服力: 把"等人批准"实现成"回合结束 + 状态留在服务端",比实现成"协程挂起等回调"简单得多,也健壮得多。

agentscope 的"收工事件为空即挂起"是同一个思路的进程内版本。 两家都把"等待"变成了"结束 + 状态可续",而不是"阻塞"。

一条接口设计:同一份客户端,两种后端

既要能连云端,也要能在本机自己跑一遍模型循环(离线、自带密钥、本地模型)。

做法是一个接口加能力位,而不是在界面层到处判断"是不是本地"。

接口的方法签名直接从服务端库的参数类型推导出来——这样接口不会和库漂移,库改了签名会直接编译报错。

这个小技巧值得记:让类型系统替你盯住"两边要一致"这件事,而不是靠人记得同步改。

它没回答什么

  • 怎么认出模型要调工具——在服务端,这一篇没讲。
  • 历史怎么压——服务端管,这一篇没讲策略。
  • 一批工具怎么并发——提到了"批量执行审批决定,带资源锁",但细节不在这一篇。

坑与代价

  • 只发增量的前提是服务端和客户端对历史的认知完全一致。 一旦不一致(客户端漏发了一条、服务端漏收了一条),双方就再也对不上,而且不会立刻报错。
  • 状态在服务端意味着离线不可用——它用"本地后端"这个第二实现来兜,等于同一套逻辑要维护两遍。
  • 一个用户回合拆成 N 次往返,每次都是一次网络请求。 延迟叠加。

    判断(无锚): 对我们的最小原型,这条路的成本远高于收益——先在进程内跑,把状态显式化(照 agentscope 的做法),等真需要"换机器接着跑"时再谈服务端。 如果错,会错在: 如果第一版就打算做成"网页点一下,后台自己跑"的形态,那状态从一开始就该在服务端,进程内跑反而是白写一遍。