很多 LLM Agent 都从一个很自然的抽象开始:session 或 thread。
最初,thread 做的事情很简单:保存 message history。用户继续输入,模型继续回答,工具调用和结果也顺着往后追加。短对话里,这几乎没有问题。
麻烦出现在任务变长以后。Thread 会慢慢承担越来越多的职责:它既保存模型上下文,又像执行轨迹;既保存用户真正关心的历史,也混进临时分析。再往后,工具日志、后台任务状态、子 Agent 结果,甚至外部系统的变化,都很容易被继续塞进同一个地方。
真正让我开始怀疑这个抽象的,不是什么宏大的架构问题,而是一个很小的需求。
假设我已经和 Agent 工作了很久,现在只想临时问一句:“把到目前为止发生的事情总结一下。”我需要这个 summary,但不希望“请总结”和 summary 本身永久写回原 session。它们只是一次辅助计算,不应该为了完成这次计算而改变被总结的对象。
最初我把这个能力叫作 Stacked Thread:从当前 thread fork 一个临时 child,在 child 中继续若干 turn,拿到结果以后丢掉 child。这个想法来自函数调用栈。
继续推下去以后,我发现问题并不只是缺一个 fork helper。更深的问题是:我们把生命周期完全不同的状态放进了同一个 conversation abstraction。
这篇文章所说的 Structured LLM Design,就是试着把这些状态重新分开。重点不是再发明一套 Agent 名词,而是回到一个很普通的软件工程原则:
不同生命周期、不同所有权、不同可见性的状态,不应该因为都和模型有关,就被塞进同一个 session。
沿着这个原则继续推,会逐渐遇到几个不同维度的问题:Context 处理模型能看到什么;Invocation、Task 和 Process 处理 computation 活多久、由谁拥有;Effect 处理已经越过模型边界、真正改变外部世界的状态。它们会在叙事中依次出现,但并不是同一条类型链。
Conversation 不够表达一次临时计算
先回到 summary。
设当前 context 是:
[A B C D]
最直接的聊天式实现会继续追加:
[A B C D
User: summarize everything above
Assistant: ...]
这当然能工作。奇怪的是,为了得到一个临时 summary,我永久修改了被总结的 conversation。
普通函数调用通常不会这样处理局部计算。Caller 提供参数和环境,callee 在自己的 activation 中运行,最后返回一个值。Caller 可以保存这个值,也可以丢掉它;callee 中间经历过什么,不会因为这次调用发生过,就自动变成 caller 的局部状态。
把这条直觉搬到 LLM runtime,更自然的行为是:
Parent context
[A B C D]
│
│ call("summarize")
▼
Child invocation
[A B C D] + [local input] + [local execution]
│
└── return summary
Parent context
[A B C D]
这里的边界需要说清楚。
Child 继承的是 call 发生那一刻 的 parent snapshot。Fork point 之后,两边各自继续:child 的后续 turn 不会自动写回 parent,parent 后来的新 turn 也不会自动进入已经存在的 child。Parent 不需要因为 child 存在而停住。
这已经不是简单的“复制一份聊天记录”。它更像一次 scoped invocation:从 caller context 建立局部执行环境,运行,返回,然后 unwind。
于是我开始觉得,LLM runtime 也许需要自己的 calling convention。
Invocation,而不是第二种 Thread
一旦有了这个想法,一个很诱人的 API 是造一个新的 StackedThread 类型,再给它 Send、SendStream、Close 之类的方法。
我后来认为这正好走反了。
如果底层系统已经有 thread、fork、turn、interrupt、unsubscribe,那么 Stacked Thread 不应该再成为第二套 thread abstraction。它描述的是一次调用关系,不是新的持久身份。
普通编程语言里也没有一种叫 StackedFunction 的新函数类型。Function 只有在发生一次 call 时,才产生 activation frame。类似地,普通 thread/context 只有进入一次 scoped fork-and-run,才形成 invocation。
更干净的分层应该是:
Protocol mechanisms
thread/fork
turn/start
turn/interrupt
thread/unsubscribe
Lifecycle composition
Continue
Call
Fork + first turn
Application patterns
summarize
critique
verify
what-if analysis
speculative exploration
这三层最好各管各的事:协议层描述 fork、turn/start 这些事实;runner/runtime 负责把它们组合成 Call 这样的生命周期;应用层再决定这次调用是 summary、critique 还是 verification。真正值得稳定下来的,是 Call 的语义,而不是给 Fork 换一个更响亮的名字。
如果落到一个 SDK,我更希望看到类似:
result, err := runner.Call(ctx, CallRequest{
ParentThreadID: parentID,
Turn: turn,
})
一个完整的 Call 需要自己的 scope。它从 parent snapshot 创建临时 child;在真正开始有副作用的 turn 之前,仍保留一个可以拒绝或取消的 admission boundary;执行完成后收集结果;如果被取消,就中断当前 turn;最后再清理 child。
而如果只是让一个已有 thread 再运行一轮,那应该是更普通的 Continue。来源于 fork 并不会让一个 thread 永久变成另一种物种。
到这里,Invocation 的含义就比较清楚了:它是一段有 caller、有局部状态、有返回值,而且结束边界明确的 scoped computation。
但马上又会遇到下一个问题:Invocation 到底在执行什么?
Context 不是聊天记录,而是执行上下文
calling convention 这个类比之所以没有很快崩掉,是因为现代 Agent 的模型输入本来就不像一串普通聊天文本。
一次调用里可能同时存在 system instruction、developer instruction、user message、assistant message、tool call、tool result、observation,以及各种结构化数据。它们来源不同、类型不同、生命周期也不完全相同,只是最终被组织成一个有顺序的模型可见上下文。
因此,message list 不只是“聊天历史”。更准确地说,它是一段按顺序组织、带有不同来源和类型的执行上下文,也就是一种 typed sequential execution context。放在一次 invocation 里看,它很像 serialized activation record。
| 编程语言运行时 | LLM Agent 运行时 |
|---|---|
| caller frame | parent context |
| call site | fork / snapshot point |
| arguments | 本次新增输入 |
| inherited environment | 已有 context prefix |
| locals / temporaries | child-local messages、tool traces、observations |
| return value | final result |
| exception | failed / interrupted run |
| unwind | invocation cleanup |
这当然不是说 Transformer 里真的藏着 CPU 风格的 stack frame。这个类比只借用运行时语义:什么从 caller 继承,什么只属于本次计算,什么应该返回,什么在 scope 结束后消失。
一旦这样看,另一个长期被混在一起的边界也会显出来:model context 和 program memory 不应该被当成同一块内存。
child invocation 的结果首先应该回到程序:
r, err := Call(ctx, req)
if err != nil {
// handle failure
}
// 程序决定 r 是否、何时、以什么形式进入某个模型上下文。
这和“把 child 最后一条 assistant message 自动 append 回 parent”是两种不同语义。前者先把结果交给程序,由程序决定怎样解释和提交;后者则直接把模型输出变成 conversation mutation。
这也是我在 llm-go 里那句原则的另一个含义:
Models propose. Programs decide. Effects require authority.
模型可以产生候选结果,程序决定是否接受、验证、排序、保存、合并,或者重新注入某个 context。模型输出不是天然的全局状态变更。
Immutable prefix 不是目标,却是一个很好的副作用
把 invocation 设计成“稳定 caller context + 局部 suffix”以后,还出现了一个意外的工程收益。
假设 parent context 是:
[A B C D]
两个派生调用分别运行:
[A B C D X1 X2]
[A B C D Y1 Y2]
它们共享完全相同的 prefix,只在 fork point 以后拥有自己的 suffix:
[A B C D]
/ \
[X1 X2] [Y1 Y2]
这种形态还有一个工程上的好处:它很适合 prefix cache 或 KV reuse。只要模型、token prefix、运行配置和 serving backend 都满足复用条件,公共 prefix 就有机会共享已经完成的前缀计算,每个 invocation 只计算自己的局部 suffix。
这里不能过度承诺。一个语义上 immutable 的 prefix,并不意味着某个具体服务端一定保留 KV。上下文压缩、隐藏 prompt 重写、cache eviction、模型变化都可能导致 cache miss。
更准确的说法是:Structured LLM Design 不主动破坏 shared prefix,因此给底层利用 prefix/KV reuse 留出了空间,但不保证 cache hit。
这点尤其适合长上下文上的派生计算。一个 100k-token 的稳定 context 可能同时被用于 summary、critique、plan、verification 和 extraction。这些本来就是独立的 sub-computation;如果推理系统能复用公共 prefix,正确的语义边界和高效的计算形态正好指向同一个方向。
到这里,我们有了 Context 和 Invocation。
如果系统永远只有一条同步执行链,Context 和 Invocation 已经够用了。问题是,Agent 很快就会遇到并发。
一个 Call Stack 遇到并发就不够了
假设一个 parent invocation 同时启动三个分析:安全性、性能和 API 设计。
Parent
/ | \
Security Perf API
这时再说“系统只有一个 call stack”就不准确了。所有 invocation 的关系更像一棵 tree;每一条正在执行的分支,只占据其中一条 active path。
Task A: Root → Parent → Security
Task B: Root → Parent → Performance
Task C: Root → Parent → API Review
换句话说,tree 描述所有 frame 的关系,stack 只描述某一个 task 当前活跃的调用链。
Invocation 只说明“一次调用怎样开始和结束”,却没有回答一组并发 child 由谁负责。谁等待它们?谁传播取消?一个失败以后,其他 sibling 怎么办?这些问题需要 Task 这一层来表达。
Structured concurrency 解决的不是“并行”,而是 ownership
同时发出三个模型请求并不难。真正麻烦的是谁负责把这三个 child 收回来。
parent 被取消时 child 是否取消?一个 child 失败是否终止 sibling?谁负责等待全部完成?如果 caller 已经结束,还有没有孤儿任务在继续烧钱、改文件、拿着 credential 活着?
这些才是 structured concurrency 真正要解决的问题。
一个 scoped task group 至少要把几件事说清楚。Child 从创建时的 parent snapshot 开始;parent scope 结束或取消时,默认把取消向下传播;child result 先回到 program memory。至于 fail-fast、wait-all、best-effort 还是 first-success,则由 caller 明确选择。
还有一个对 LLM 特别重要的约束:completion order 不应该自动成为 conversation order。
否则三个 child 谁先返回,谁就先被 append 回 parent,于是:
same inputs
+ different scheduling
= different conversation state
这等于把网络延迟和调度抖动变成了模型语义。
更稳妥的做法,是让每个 child 先返回 typed result,再由 caller 用确定性的逻辑排序、验证、去重或聚合。只有 caller 明确提交的内容,才进入下一次模型 context。
这也解释了为什么 Stack 这个比喻仍然成立,但必须被放到更大的结构里:
persistent context tree
+ one active stack per task
+ structured task scopes
所以,Invocation 和 Task 的区别不在“谁更异步”。Invocation 描述一次 scoped computation;Task 处理的是仍然属于同一个 structured scope 的并发工作。
接下来真正改变归类的是生命周期:如果某个 activity 在创建它的 scope 已经结束以后还能继续存在,它就不再是普通 child task。
当活动可以比 caller 活得更久时,需要 Process
CI、部署、训练、远程 build、后台服务都有一个共同点:创建它们的 invocation 结束以后,它们仍然可能继续推进。
这里的判据不是“运行得够不够久”。Async 描述执行方式,Process 描述生命周期。Streaming 也是一样。 一个 activity 是否应该成为 Process,只看一件事:creator scope 结束以后,它是否还可以继续存在。
如果 creator 已经结束,activity 却还要继续运行,再把它当成普通 child task,ownership 就会悬空。要么 caller 被迫一直挂着,要么系统产生一个没人真正负责的 orphan task。
这时,Task 就不够用了。
我更愿意把三种执行形态明确区分开:
| 形态 | 生命周期 | 典型语义 |
|---|---|---|
| Call / Invocation | scoped | 调用、返回、unwind |
| Task | scoped concurrent | fork/join、取消传播、failure policy |
| Process | 可以超出 creator scope | spawn、supervise、wait/cancel |
Process 不再只属于某个 conversation frame。它可以在 creator invocation 返回以后继续存在,所以需要由寿命更长的 runtime 或 supervisor 持有。Creator 结束以后如果还要再次找到它,Process 也就需要稳定的 identity 或 handle。
这正是 CSP-style communication 开始真正有用的地方。
对于一个长时间运行的 CI,更自然的结构是:
caller
│ spawn
▼
CI process
├─ Started
├─ Progress
├─ Output
├─ Request
├─ Failed
└─ Completed
调用者拿到的不是“十分钟后的最终字符串”,而是一个 process handle:
type Process interface {
ID() ProcessID
Events() <-chan ProcessEvent
Wait(context.Context) (ProcessResult, error)
Cancel(context.Context) error
}
CSP 在这里提供的启发,不只是“channel 可以异步传消息”。更重要的是,独立活动可以通过通信暴露变化,而不需要某个模型 frame 一直挂着、共享并轮询它的内部状态。
从 ownership 的角度,可以把几层关系写得更直接:
Invocation owns invocation-local state
Task scope owns scoped Tasks
Runtime / Supervisor owns Processes
Authority governs Effects
这几层真正区分的是“谁负责收尾”。Invocation 的局部状态随调用结束而 unwind;Task 默认不能无主地逃出 structured scope;Process 一旦独立存在,就需要寿命更长的 owner。Effect 更特殊:即使这些 scope 全部结束,它造成的外部变化也不会自动消失。
这也让我重新理解以前更偏事件驱动的 Agent 设计。在 《谁在调用谁:用通信过程和协程理解大语言模型智能体》 里,我更强调 process、channel 和 coroutine,因为长任务确实很难用同步调用链表达。
现在看,这两种视角并不冲突。Call 处理 scoped computation,Task 处理 scoped concurrency,Process 处理可以超出 creator scope 的活动。CSP 则解释这些活动怎样通过 channel 协作,它不是拿来替代 call stack 的。
这里还要区分两个同名概念:CSP 里的小写 process 是通信过程;本文的大写 Process 是生命周期对象。一个 activity 可以用 CSP process 的方式运行,却仍然属于某个 Task scope。只有它能够超出 creator scope,才应该被归为这里的 Process。
Event 和 Channel 不是新的生命周期
这里还要避免把所有有用名词都提升成新的顶层对象。Process 会产生 event,也会通过 channel 通信,但这不意味着 Event、Channel、Observer 也是新的生命周期类型。
两组概念回答的是不同问题。Invocation、Task 和 Process 主要回答 lifetime 与 ownership;Event、Channel 和 Observation 回答 communication。它们是两个正交维度:
Lifecycle / ownership
Invocation
Task
Process
Communication
Event
Channel
Observation
Task 可以产生 event,Process 也可以;channel 也能连接不同生命周期的对象。通信方式不决定生命周期。
LLM context 不应该变成 runtime event log
Process 模型还带来一个直接的上下文设计结论:后台系统产生的大部分事件,不应该进入 LLM context。
一个运行二十分钟的 CI 可能每几秒就产生日志和 heartbeat。如果每一次 still running 都写进 conversation,context 会迅速膨胀,shared prefix 也不断变化,真正重要的信息反而更难被注意到。
更合理的路径是:
Runtime Event Stream
│
│ filter / aggregate / select
▼
Relevant Observation
│
▼
Next LLM Invocation
原始事件可以完整留在 runtime 里,模型只需要看到会影响判断的部分。这正是 《从工具调用到事件驱动:大语言模型智能体的运行时架构》 里 observer/planner 的分工:observer 消费高频 signal,planner 消费经过筛选的语义变化。可以把这个边界压缩成一句话:events wake the runtime; observations wake the model.
有了 channel,Go/CSP 风格的 select 就很自然。Runtime 可以同时等 CI 完成、用户追加输入、另一个 child 返回、timeout 或外部状态变化。谁先 ready 就先处理谁;只有变化真的需要解释、重规划或产生新 action 时,才重新调用模型。
这时模型更像被 runtime 按需唤醒的 planner,而不是永远占据主循环顶部的“主程序”。
到这里,Context 以及 Invocation、Task、Process 的边界已经比较清楚了。
但还有一个与生命周期正交的问题:这些 computation 一旦改变了外部世界,scope 结束以后会发生什么?
另一个正交问题是 Effect
清理 child invocation,不等于它写过的文件会消失;取消 task,也不会自动撤销已经发出的网络请求。Process 被终止以后,外部 SaaS 里的资源同样可能继续存在。
这正是 stack 类比最容易误导人的地方。Conversation-local state 更像 stack-local state;workspace、数据库和外部系统则更像 shared heap 或 external world。函数返回以后 locals 会消失,但它改过的全局变量不会因此回滚。Agent 也是一样。
因此:
Frame isolation does not imply effect isolation.
一个临时 child thread 被清理,只说明这段 conversation execution 结束了。它写进 workspace 的文件、发出的 HTTP request、创建的 cloud resource 仍然可能存在。
这就是为什么 Effect 必须从 Context / Invocation / Task / Process 中单独拿出来。
如果需要更强的隔离,就要另外提供 read-only capability、workspace snapshot、sandbox、transaction 或 external mutation approval。它们属于 effect system。Invocation 是 ephemeral,并不自动意味着 effect 也会回滚。
这里同样需要 mechanism 和 policy 的分离。runtime 可以提供 interrupt、sandbox、approval callback、transaction boundary;应用决定什么操作允许发生,什么时候需要确认,哪些资源可以访问,什么结果可以提交。
于是那句原则终于完整落地:
Models propose. Programs decide. Effects require authority.
模型负责提出行动和候选结果;程序掌握 workflow state,并决定什么可以提交;真正改变外部世界的操作,则必须经过明确 authority。
Structured LLM Design 最终在结构什么
回头看,这套结构不是从几个漂亮方框开始的。每个概念,都是旧抽象解释不了新问题以后才出现。
最初只有 Conversation。一个“不应该污染原 session 的 summary”说明 Conversation 不够,于是需要 Invocation。把 Invocation 看成 activation 以后,Context 的边界也跟着清楚了:什么从 caller 继承,什么只属于本次调用,返回值为什么应该先回到 program memory。
多个 invocation 并发以后,一个 call stack 不够,于是需要 Task 和 structured concurrency。Activity 可以比 caller 活得更久以后,Task 的 scope 又不够,于是需要 Process。与此同时,Event、Channel 和 Observation 负责描述这些对象怎样通信。
Effect 的发现顺序虽然靠后,但它不是 Process 之后的“下一层生命周期”。从第一次 Invocation 开始,只要 computation 能修改文件、数据库或外部服务,Effect 就已经存在。它始终是一条正交的 authority 轴。
因此,最终模型更适合按几个维度来表达:
Context / visibility
Context
Lifecycle / ownership
Invocation
Task
Process
Communication
Event
Channel
Observation
Effects / authority
Effect
Capability / Approval
Sandbox / Transaction
这里真正稳定的不是一条从 Context 走到 Effect 的继承链,而是几个彼此正交的边界:模型看见什么,computation 活多久、由谁负责,不同活动怎样通信,以及什么操作有权改变外部世界。
这里还有一个工程上很有意思的结构观察,但需要把话说弱一点。
在典型的 fork-from-parent workload 中,context branching、structured task ownership 和 KV prefix reuse 往往都受益于同一类形态:稳定的 ancestor,加上局部 branch。它们可能在同一组 fork point 附近对齐,但并不要求彼此同构。
Task 可以使用并非来自 parent fork 的 context;Process 后续也可以创建新的 Invocation;serving backend 的 KV cache 更可能由 page、hash 或 trie 一类内部结构实现。这里真正有价值的只是:上层如果保持稳定 prefix 和局部分支,就不会主动妨碍底层复用。 对某个正在执行的 task 来说,它自己的 call stack 仍然只是 invocation 关系树上的一条 active path。
从 calling convention 的角度,还可以把这套关系理解成一种 LLM execution ABI。Caller 提供稳定 context,call boundary 固定 snapshot,新增输入作为 arguments,callee 只在自己的 suffix 中运行。结果按明确的 return convention 回到 program memory;scope 结束时 frame unwind;外部 mutation 则走独立的 effect authority。
底层 provider 不需要统一实现。一个系统可以用 thread fork,另一个可以用 session snapshot、prompt prefix 或 server-side cache。上层真正需要稳定的是语义:
stable context + scoped computation + structured ownership + explicit communication + explicit effects
这也是为什么我不再把最终设计叫作 Stacked Thread。Stack 仍然重要,但它只解释 scoped invocation 当前走到哪一层。Structured LLM Design 想处理的是更大的问题:怎样让 LLM computation 的 context、lifetime、concurrency、communication 和 effects 都有明确边界。
这样再回头看,session 污染、子 Agent、KV cache、并发 fan-out、后台 CI、事件流和工具权限,就不再像一堆碰巧遇到的问题。它们更像是同一个 runtime design problem 从不同层面露出的截面。
而如果这个判断成立,Agent runtime 最值得做的事情,可能不是继续往 session 里塞更多能力,而是开始认真地把它拆开。