ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

让 Agent 变成一堆互相调用的服务

让 Agent 变成一堆互相调用的服务 文章目录前言一、先说清楚为什么 while 循环是个单体二、Durable Execution 给了我答案的一半三、核心洞察把 Agent 当成只有一步的东西3.1 用 DDD 的话来说这套设计3.2 整体架构四、数据模型五张表讲的其实只有一件事五、两个状态机5.1 会话状态机7 态5.2 工具调用状态机六、关键难点难点 1单飞——两个事件同时到只能有一个跑难点 2fencing——僵尸 worker 的写必须无效难点 3LLM 幂等——唯一真正花钱的地方难点 4提前唤醒防御难点 5SSE 断线重连难点 6超时常量之间的关系七、恢复分层四道防线八、第一版根本没跑起来这段是全文我最想写的8.1 P0消费端只有 XAUTOCLAIM没有 XREADGROUP8.2 P0SSE 永远收不到事件8.3 P1一个谓词废掉整个取消功能九、这个方案不做什么十、回头看这个设计P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者这个教程里内容讲解通俗易懂且风趣幽默对我帮助很大。我想与大家分享这个宝藏教程请点击下方链接查看 传送门https://blog.csdn.net/qq_74013365前言去年年底我干了一件在同事眼里像脑子进水一样的事把自己写的那段while 循环拆了。不是那种删了重写的拆而是把整个 Agent 拆成了一堆互相调用的服务。拆完之后我发现一个扎心的事实那段 while 没有 bug它只是长错了。先说说我们当时为什么痛。线上跑着几千个会话一个用户一个 Docker 容器Agent 的循环整个塞在容器里调 LLM、跑工具、再调 LLM转个不停。第一个月看监控我差点以为报表坏了容器 CPU 平均利用率不到 5%。因为 Agent 九成时间在等 LLM 返回、等工具执行、等用户点确认。换句话说我们为 5% 的利用率付了 100% 的钱。这感觉就像公司给一个天天躺平睡觉的员工发全勤奖。第二个问题更狠。某次宿主机抖了一下几十个正在跑工具的容器同时挂了。用户会话的对话历史、跑了一半的代码、装好的依赖全没了——因为它们只活在那个容器的内存里。写了一半的文档没保存顶多砸个键盘这里直接是几十个用户的心血当场蒸发。第三个问题是网络。每个容器要开自己的网关转发流量、要接 Consul 做服务发现、还要打通内网。运维同学每天都在解决本不该存在的问题比如容器为什么连不上自己的邻居。那晚我盯着那段 while 看了很久。它没有 bug但它用微服务时代的架构范式——一个进程干完一整件事——去承载一个本质上属于分布式系统的东西。于是我做了一个当时看起来有点疯的决定拆掉 while 循环让 Agent 变成一堆互相调用的服务。这篇文章不讲理论只讲我踩的坑、想明白的事以及最后那套跑通的架构。一、先说清楚为什么 while 循环是个单体我们习惯用单体 vs 微服务来形容大系统但 Agent 循环和微服务之间有一个惊人的同构性。这也是我后来想通的全部起点。一个典型的 Agent 循环长这样while (没结束) { 响应 LLM(历史消息) if (响应里有工具调用) { 结果 执行工具(...) 历史消息.push(结果) continue } 返回给用户 }把它和传统单体对比一下单体应用Agent 循环状态在进程内存里状态在容器内存里一次调用内完成一次会话跨越几十分钟到几小时进程常驻等下一个请求进程常驻等下一个工具调用可能等十分钟扩缩容 加机器扩缩容 加容器1:1没有杠杆进程挂了 重新接请求进程挂了 会话状态全丢依赖 函数内调用依赖 容器网络 网关 服务发现上面那张表最后一行是灵魂所在。单体里 orderService.pay() 是一次函数调用毫秒级失败了重试就行。Agent 里 runShell(“npm install”) 是一段跨越几千毫秒、可能产生半成品文件、可能烧掉几十美金的外部副作用。这两者的失败语义根本不同。你用同一套代码结构去承载它们必然痛苦。就像你用同一个锅既煮泡面又炼钢锅不炸才怪。这个痛我起了个名字唤醒成本决定休眠策略。重建一个容器要秒级一个会话空闲期可能有十分钟于是我们只能拍脑袋定个 30 分钟 TTL——不敢睡太久浪费钱也不敢睡太短把用户等醒了。说白了TTL 是个被迫的启发式妥协它掩盖了真正的问题状态被绑在了计算上。二、Durable Execution 给了我答案的一半如果你关注分布式系统一定听过 Temporal。它解决的问题是写一段业务逻辑进程随时可能崩溃但这段逻辑最终会跑完。做法是 event sourcing——把逻辑执行过程中每一个决策都记成事件崩溃后从事件流重放出内存状态继续跑。Durable Execution 最有价值的一句话是它是 crash-proof execution。你只写 happy path故障处理交给平台。我当时的反应是这就是我要的。但冷静下来后发现Agent 场景和传统工作流有两个关键差异。差异 1Agent 的活动是不对称的。Temporal 里 activity 通常是调一个 API几百毫秒。Agent 的 activity 是 LLM 推理——动辄 30 秒到 5 分钟token 成本真实存在而且重跑一次是要花钱的。事件溯源能保证不重复执行已完成的决策但保证不了崩溃在 LLM 刚返回、结果还没落库那一刻不重复花这笔钱。翻译成人话钱付了货还没到家快递公司破产了你得再付一次。**差异 2Agent 的执行体很重。**Temporal 假设 activity 跑在你自己的 worker 进程里毫秒级拉起。但 Agent 需要一个能装下 Node、Python、浏览器甚至完整 Linux 发行版的沙箱。Temporal 不会替你解决这个问题就像外卖平台不会替你把菜炒熟。于是我意识到我需要的不是Temporal 的事件溯源而是一个专门为 Agent 定制的、单步级别的、可休眠可恢复的运行时。我最后的选择是借事件溯源的思想journal 是唯一真相源但自己实现执行层。回头看这个决定是对的——不是因为 Temporal 不好而是把 LLM 调用当成 activity会有一个崩溃在写库前的窗口通用事件溯源堵不住它只保证不重跑已记录的步骤不保证不重跑正在执行的步骤。这个窗口只能靠意图前置 幂等键来堵这正是我后面 I4 不变量的由来。三、核心洞察把 Agent 当成只有一步的东西一句话概括我的设计Agent 的最小可恢复单位不是会话而是一次推理。整个系统只做一件事拿到一次推理机会把当前状态喂给 LLM把结果落库然后决定下一步谁来。伪代码就这么点Step(session): 拿租约防并发 读 journal → 重建 context 调 LLM 单事务写journal step 记账 工具意图 唤醒信号 释放租约 退出进程“tool-use” 和 “纯文本回复” 两种结局if 响应有 tool_calls: 写 tool_call 意图 → 投递「执行工具」信号 → 我退出 else: 写 assistant.message done → 我退出进程退出后什么都不占着连个念想都没有。下一步的触发者有两个工具执行完回推gateway 回调或者用户又发了一句话。两个触发都通过队列回到再跑一次 Step。中间无论多久无论哪个进程挂了状态都在 journal 里躺着。就像存折不怕断电也不怕银行倒闭银行倒闭另说。3.1 用 DDD 的话来说这套设计如果你喜欢领域建模这套东西其实很自然。限界上下文Bounded Context划成三个上下文职责语汇Control控制面会话状态机、journal、决策status、event、step、tool_call、outboxExecution执行面跑一步推理RunStep、context、lease、fencingSide-effect副作用面跑工具、连沙箱tool_call、sandbox、lease、attach聚合根Aggregate Root是 session。所有写状态的操作都必须经过它而且——我觉得最值钱的一条设计——所有状态迁移都必须带来源状态白名单-- worker 写必须持租约 fencing 匹配UPDATEsessionSETstatus:to,versionversion1WHEREsession_id:sidANDlease_owner:meANDfencing:my_fencingANDcancel0ANDstatusIN(:from_whitelist);-- ← 关键不允许无条件覆盖-- 控制面写只允许窄条件迁移UPDATEsessionSETstatus:toWHEREsession_id:sidANDstatusIN(:from_whitelist);代码层面我强制了 from 列表不允许为空——系统里不存在一条能无条件改状态的 SQL 路径。听起来像洁癖但它一次性消灭了一整类 bug。洁癖在系统里不是病是防御力。领域事件Domain Event就是 journal 里的 event 表它同时是审计日志、/history 接口的数据源、context 重建的原料、SSE 的内容源。**一份数据四个用途。**这个复利单体架构给不了余额宝也给不了。防腐层ACL出现在两处LLM 端点我只关心给我 context还我一个带 tool_calls 的响应不关心 OpenAI/Anthropic/Qwen 的协议差异和沙箱工具执行只认 Sandbox 接口将来换 OpenSandbox / CubeSandbox 不用动业务。3.2 整体架构┌──────────────┐ │ api │ HTTP/SSE 边缘无状态 │ 写 journal outbox单事务 └──────┬───────┘ │ outbox 轮询发布 ┌──────▼───────┐ │ dispatcher │ ① outbox 发布器DB→Redisat-least-once │ │ ② reconciler检测 stall 会话并补投自愈 └──────┬───────┘ │ XADD ┌──────┼──────────────────────────────┐ │ agentmicro:work (组:agents) │ │ agentmicro:tool (组:mcp) │ └──────┼──────────────────────────────┘ ▼ ┌─────────────┐ ┌─────────────┐ │ agentstep │ │ mcpgw │──▶ sandbox │ 单步执行器 │◀────────│ 工具执行 │可换 OpenSandbox / │ 租约→LLM→落库│tool.result │ CubeSandbox └──────┬──────┘ └─────────────┘ │ journal 事件提交后精确镜像 ▼ agentmicro:events:{session_id} ◀── SSE 客户端offset Redis entry ID四个进程各自独立部署、独立扩缩容。真相源是一个 SQLite 文件WAL 模式。插一句我原本是写了 Kitex RPC 层的RunStep(session_id) 这样的单步服务后来在 MVP 阶段主动删掉了——四个进程共享同一个库直接调库函数更简单状态机一行没改。RPC 层是后面加回去的接口边界早就切干净了。有时候砍掉一层抽象比补一层更划算就像减脂比增肌见效快。四、数据模型五张表讲的其实只有一件事-- 会话状态机 单飞凭据CREATETABLEsession(session_idTEXTPRIMARYKEY,statusTEXTNOTNULL,-- 7 态状态机见第五节next_step_seqINTEGERNOTNULL,lease_ownerTEXT,-- 单飞谁在跑lease_expire_atINTEGERNOTNULL,fencingINTEGERNOTNULL,-- 单飞第几次接管cancelINTEGERNOTNULL,-- 取消标记fail_countINTEGERNOTNULL,ctx_digestTEXTNOTNULL-- memo 校验用);-- journal消息级真相源CREATETABLEevent(session_idTEXTNOTNULL,seqINTEGERNOTNULL,typeTEXTNOTNULL,-- user.message / assistant.tool_call /-- assistant.message / tool.call /-- tool.result / ask_user / error / donepayloadTEXTNOTNULL,PRIMARYKEY(session_id,seq)-- 会话内单调递增);-- LLM 调用的幂等账本CREATETABLEstep(session_idTEXTNOTNULL,step_seqINTEGERNOTNULL,statusTEXTNOTNULL,-- inflight / done / failedrequest_idTEXTNOTNULL,-- sid|seq|ctx_digest|attemptctx_digestTEXTNOTNULL,attemptINTEGERNOTNULL,PRIMARYKEY(session_id,step_seq));-- 工具调用意图 → 核销对账层CREATETABLEtool_call(tool_call_idTEXTPRIMARYKEY,session_idTEXTNOTNULL,statusTEXTNOTNULL,-- dispatched/running/waiting_user/done/failedrunnerTEXT,-- 执行租约谁在跑这个工具run_expires_atINTEGER,is_errorINTEGERNOTNULL);-- 事务性 outbox状态与要发什么信号同事务落盘CREATETABLEoutbox(idINTEGERPRIMARYKEYAUTOINCREMENT,session_idTEXTNOTNULL,kindTEXTNOTNULL,-- STEP / TOOLpayloadTEXTNOTNULL,publishedINTEGERNOTNULL);这五张表有个共同点没有一张存当前对话状态这种聚合字段。状态是算出来的——rebuild_context(journal)。这是整个设计的立足点只要真相源是追加写的恢复就是免费的一旦你开始 UPDATE 一个 conversation 字段恢复逻辑立刻开始腐烂。这就像记账流水只增不改账永远平你非要天天涂改账本迟早被查。tool_call 是我最想强调的一张。它是意图表不是任务表LLM 决定调工具的那一刻就先落一行 dispatched然后才投递信号。如果这时候 gateway 挂了、工具压根没跑reconciler 看得见这个有去无回的意图并重新投递如果工具跑了但结果没写回来状态还停在 running靠执行租约超时终结。意图和结果分离是副作用可对账的唯一前提。翻译先把要干的事写进备忘录再去干活干完划掉。别干完才想起来写那叫后悔不叫对账。五、两个状态机5.1 会话状态机7 态POST /task ──▶ PENDING ──▶ RUNNING RUNNING ──(LLM 出终答)──────────▶ DONE RUNNING ──(新工具意图)──────────▶ WAITING_TOOL ──(工具全终态)──▶ RUNNING RUNNING ──(gateway 停泊)────────▶ WAITING_USER ──(新一轮输入 revive)──▶ RUNNING 任意状态 ──▶ CANCELLED FAILED ──(可被新一轮输入复活)迁移规则全部走白名单没有例外。两个反直觉但必要的细节**RUNNING → WAITING_TOOL 之后不能直接回 RUNNING。**必须等所有 tool_call 都进终态WakeIfToolsSettled。一个响应里有 3 个工具调用第 1 个先回来就唤醒模型模型看到的 context 里 2 个 tool.result 缺失——真实模型端点会直接报 400。这就好比饭还没上齐你就喊服务员结账人家以为你要逃单。**取消必须对所有状态可达包括 RUNNING 中。**我第一版在拿租约的 SQL 里加了 AND cancel 0看起来很合理结果是RUNNING 中点的取消因为 worker 一直续租、下次拿租约永远拿不到这个会话永远卡在 RUNNING。一个谓词废掉整个取消功能。用户点了停止状态条还在转跟关不掉的水龙头一样。5.2 工具调用状态机dispatched ──(gateway 认领)──▶ running ──(执行成功)──▶ done │ ├──(执行失败)──▶ failed │ └──(ask_user 工具)──▶ waiting_user ──(用户回答)──▶ done 终态不可变done / failed 只能被读不能被再次改写重复投递时直接短路返回六、关键难点难点 1单飞——两个事件同时到只能有一个跑tool 的回调和用户新的一句话可能同时触发一次 Step。如果不管模型会基于同一份历史跑两次烧两倍的钱journal 里写进两条互相矛盾的 assistant.message。这就像同一个人领了两份工资还互相不知道。解法是老朋友条件 UPDATE 抢租约。UPDATEsessionSETlease_owner:me,lease_expire_at:nowTTL,fencingfencing1WHEREsession_id:sidAND(lease_ownerISNULLORlease_ownerORlease_expire_at:now);影响行数为 1 才算抢到同时拿到新的 fencing token。注意这里不能加 cancel 0 谓词原因见 5.1。难点 2fencing——僵尸 worker 的写必须无效租约过期后worker B 接管了会话。此时 worker A可能只是 GC 停顿了几秒醒过来继续写状态。如果不加防护A 的写会覆盖 B 的结果。僵尸吃脑子你的成果就是那个脑子。标准解法是 fencing token每次接管 fencing 1所有 worker 写库时必须带上 fencing :my_fencingfunc(w*Worker)submit(ctx context.Context,to Status)error{n,err:w.store.TransitionSessionWorker(ctx,w.sid,to,w.owner,w.fencing)iferr!nil{returnerr}ifn0{returnErrZombie// 条件不匹配有人接管了我直接放弃}returnnil}但这里有个我一开始想错的语义边界值得单独说。fencing token 的经典语义是新 holder 的 token 更大旧 token 全部作废。那如果租约过期了、还没人接管旧 worker 这时才提交算不算僵尸我一开始写了个 TestFencingRejectsZombie 就以为完事了后来单独想清楚只要没人接管过fencing 值没变单飞事实上仍然成立这次提交是安全的。测试最终拆成两个TestFencingRejectsZombie——B 接管后 A 的写必须失败TestLateCommitWithoutTakeoverSucceeds——A 迟到但无人接管时提交必须成功。这两种行为在测试里长得几乎一样但语义完全相反。如果只测前者我的系统会在租约超时但网络抖动没人接管的场景下丢失一整轮 LLM 结果。那种 bug 就像考试只复习了第一题结果卷子上全是第二题。难点 3LLM 幂等——唯一真正花钱的地方这是整套系统里我最小心的地方。崩溃窗口长这样worker A: 调 LLM ✅花了 3 块钱 → 进程被 kill ✗ worker B: 重新调 LLM ❌再花 3 块只在 journal 落库是防不住的因为崩在LLM 已返回和结果已落库之间那几毫秒里。其他地方崩了只是尴尬这里崩了是破产。我的解法是把意图前置调 LLM 之前 INSERT step(session, seq, statusinflight, request_idsid|seq|digest|attempt, attemptn) 调 LLM 之后 单事务写 journal step.statusdone session.ctx_digest于是恢复时的判断变得很清晰step 记录含义动作done 且 ctx_digest 命中上次已经成功这次是重复唤醒NOOP绝不重发唤醒否则死循环inflight上次崩在写库前attempt 1重调并打点告警——这说明有窗口无记录首次执行正常调attempt 字段让重复调用变成可观测的而不是隐形的。生产化时把 request_id 透传给支持幂等键的 LLM 网关这个窗口就能彻底关掉——但在那之前attempt 0 的计数就是我的告警信号。顺带说个细节deltatoken 增量事件是乐观推送的提交前就发出去了。如果这次 step 崩了重跑用户会看到两遍流式输出。我不打算解决它——因为 message 事件最终文本才是对账基准客户端拿它去重即可。用一条非真相源的事件换取首 token 延迟这个交易很划算。就像超市试吃先尝一口不好吃你也没损失。难点 4提前唤醒防御我加了一条看起来很傻的前置检查func(s*Step)RunStep(ctx context.Context,sidstring)error{sess,_:s.store.GetSession(ctx,sid)switchsess.Status{casestore.WaitingUser:returnnil// 等人别动casestore.WaitingTool:pend,_:s.store.PendingToolCalls(ctx,sid)iflen(pend)0{returnnil// 工具没跑完绝不调 LLM}}...}没有这条mcpgw 因为任何原因提前唤醒消息重复、reconciler 补投、运维手动重放agent 就会拿着残缺的 context 去推理。这就像你还没睡醒被人从被窝里拽起来开会说出来的话全是胡话。这条检查是防御不变量而非优化。难点 5SSE 断线重连SSE 推送用 Redis Stream每会话一条客户端断线重连要能续读。我第一版的做法是自己维护一个 offset 字段然后立刻遇到了裁剪后的 offset 悬空问题。正确做法是直接用 Redis Stream 原生 entry IDms-seq当 SSE 的 id 字段重连时用 Last-Event-ID 头传回来服务端 XREAD 从该 ID 之后继续id: 1727...-0 event: tool.result data: {tool_call_id:tc_1,content:echo:hello}零维护成本精确续读不需要任何 offset 映射表。唯一补的兜底是Stream 有 MAXLEN 裁剪客户端要完整历史时走 /historyjournal 永不裁剪。你维护一个 offset 的维护成本比不维护还高这是真事。难点 6超时常量之间的关系这种系统最怕的就是改一个参数炸三个地方跟拆炸弹似的剪错一根线全楼停电。我把这些约束全部显式化参数默认关系LEASE_TTL15sRENEW_INTERVAL5s TTL/2续租失败立即中止 LLM 调用LLM_TIMEOUT5mSTEP_MAX_ATTEMPTS3烧钱上限 3 × 单价WORK_MIN_IDLE60s LLM_TIMEOUT正在推理的消息不会被误回收TOOL_TIMEOUT60sTOOL_MIN_IDLE90s TOOL_TIMEOUT重投时在途工具必然已终结不会二次执行STALL_AFTER30s WORK_MIN_IDLEreconciler 是主恢复路径消息回收是兜底两条加粗的约束是我踩出来的。TOOL_MIN_IDLE TOOL_TIMEOUT 时一个还在跑的工具会被重复投递 → 重复执行副作用重复扣款、重复发邮件。这类 bug 在低并发测试里几乎抓不到只在生产环境的抖动里现形。就像那种平时听不见、一吵架就特别致命的矛盾。七、恢复分层四道防线一个任意时刻任意进程可能死的系统恢复不能只有一条路。跟保险一样你得买四份全部幂等① outbox 发布 状态事务提交时同时写 outbox → 发布器轮询投递 覆盖99.9% 的正常路径 ↓ 消息丢了 / 发布器崩了 ② XAUTOCLAIM 消费者组 回收超过 MinIdle 未 ACK 的消息 覆盖worker 崩溃消息还挂在 PEL 上 ↓ 消息被 ack 了但处理失败 / 消息压根没产生 ③ reconciler 检测 stall 会话无待发 outbox、无在途工具、租约已过期、updated_at 过旧→ 补投唤醒 覆盖任何静默失败兜底 ↓ 以上都失效理论上 ④ 租约 memoize 重复投递本身不产生副作用第 ③ 层是实践中最有价值的一层它的判定条件我调了好几轮——一开始的 SQL 引用了一个不存在的列错误被 err ! nil 吞掉reconciler 静静地什么都没干。这种静默失效的兜底比没有兜底更危险因为它给了你虚假的安全感。就像家里摆着一个灭火器看着挺安心其实里面是空的——不比空的更坏它让你以为安全。现在我给每一层都配了独立的测试并且故意用故障注入验证。八、第一版根本没跑起来这段是全文我最想写的我把这个单独拎出来。上面那些难点听起来像是理论推演实际上我 v1 写完之后一行都没跑起来。逐段评审后列了 14 个问题选三个最有代表性的8.1 P0消费端只有 XAUTOCLAIM没有 XREADGROUPXAUTOCLAIM 的语义是回收已经进 PELPending Entries List的消息也就是消费者拿过但没 ACK 的。而一条全新发布的消息根本不在 PEL 里——XAUTOCLAIM 永远看不见它。结果就是整条管道饿死。outbox 发布器在拼命 XADD消费者在拼命回收空气。而单测会绿因为 mock 的 Redis 返回空列表代码路径没报错。绿得很安详死得也很安详。修复XREADGROUP 取新消息是主路径XAUTOCLAIM 只做兜底回收。测试名就叫 TestReadNewDeliversFreshMessages——专门验证新消息能被立刻拿到。8.2 P0SSE 永远收不到事件我把事件写进了 journal然后……忘了推流。SSE 端点一直在那里转圈。这个 bug 的恶劣之处在于它不会失败只会沉默。HTTP 200连接保持什么都收不到。就像客服跟你说您的问题我们已经记录了然后就没有然后了。修复方式是在 journal 事务提交后由 relay 精确镜像到 events:{session_id} 流并且镜像的字段要和 journal 事件一一对应assistant.tool_call 只进 journal 不下发因为它是内部事件。8.3 P1一个谓词废掉整个取消功能前面提过AcquireLease 里写了 AND cancel 0。从直觉上看这很对——都取消了你还抢什么租约。但它造成了死锁RUNNING 状态下的会话取消后worker 的续租会持续成功因为 cancel 不影响续租路径于是再也没有人能拿到租约完成这次 step会话永远停在 RUNNING。用户点了停止状态条还在转。转得比某些长篇动画还长。修正是让取消走控制面直接迁移只要不是 worker 正在写租约有效且 RUNNING控制面直接把状态推到 CANCELLEDworker 提交时撞 cancel 1 条件失败通过 reconcile 收敛到 CANCELLED。教训状态机的可达性需要显式验证。我现在为每个状态都有一条从任意状态可达终态的测试。这可能是我整个项目里最贵的一课**mock 如果比真实端点宽容测试就是在给错误的实现背书。**后来我让 mock 也走完整协议读 tools、校验消息交替合法性绿灯才有意义。绿灯要是只看外观那跟摆摊算命有什么区别。九、这个方案不做什么技术文章只讲好话是耍流氓说几个边界**不解决多租户鉴权与配额。**tenant_id 已经贯穿 session 表但鉴权没做。gateway 是天然的安全咽喉所有副作用都从它出去按 session 限流和配额是下一步最该补的。**不解决真沙箱隔离。**本地实现是命令白名单 工作区目录这是演示级不是安全边界。真上线必须接 OpenSandbox / CubeSandbox 这类Sandbox 接口已经留好了。**SQLite 只适合单机 MVP。**四个进程共享一个 DB 文件靠 WAL 撑着能跑但不能扛并发。生产换 MySQL/PG租约逻辑换成 SELECT … FOR UPDATE 或 UPDATE … WHERE语义完全不变。**事件流与 journal 非原子。**SSE 是通知层journal 是真相源。这个缝隙是有意留的——为了首 token 延迟。客户端必须以 message/done 事件对账/history 兜底。**不适合的场景。**如果你的 agent 交互是一次请求一次响应无状态问答这套东西纯属自找麻烦。它的价值只在会话足够长、等待足够多时才显现。如果是本地单机工具Claude Code 那种常驻进程 事件历史的形态更合适。第 5 点我想多说一句不是所有 agent 都该被微服务化。拆分的代价是每一次工具调用多两次队列往返和一次状态重放。如果你的工具调用是亚秒级的、交互是同步的这笔账算不过来。就像你为了一颗葱专门开了一个菜市场。十、回头看这个设计做完了我最想记下来的不是某个技术点是三个认知。**第一while 循环是边界不是实现细节。**当你的循环体里出现了跨分钟的外部副作用和需要在任意点崩溃后恢复的状态这个循环就已经是一个分布式系统了只是它还穿着单体的衣服。识别出这个边界比选任何技术栈都重要。就像你对象生气不是因为你做错了什么而是因为你没发现哪里不对劲——算了这个类比不太严谨反正你懂我意思。**第二状态外置的最大收益不是能恢复是能审计。**我原本只想要恢复做完发现收益最大的反而是 journal——它同时是 SSE 的内容源、context 的原料、/history 的 API、排障时的唯一线索。一份数据四个用途。这种复利是单体架构给不了的。第三把不变量写进代码结构而不是写进文档。“状态迁移必须带来源白名单”“fencing 必须校验”“inflight 必须前置”——这些如果只写在 wiki 里三个月后必然有人写出一条绕过路径。但如果它们是 TransitionSessionWorker 这一个函数里不可绕过的条件那就永远不会被破坏。架构的稳健性来自于把约束收敛到少数几个 choke point。文档会过期代码结构不会程序员会离职choke point 不会。至于 Durable Execution 这个方向我的结论可能有点反主流Temporal 解决的是业务逻辑的持久化执行Agent 需要的是认知状态持久化 副作用对账这两件事只有一半重合。前者可以复用后者必须自己写——因为 LLM 调用花钱、工具调用有真实副作用这两件事的幂等性无法从通用的事件溯源里免费得到。免费的东西最贵这句话在分布式系统里也是真的。最后如果你也在拆自己的 while祝你好运。拆对了是架构拆错了是裁员。P.S. 推荐一个大神的教程给想要了解或者学习人工智能知识的读者这个教程里内容讲解通俗易懂且风趣幽默对我帮助很大。我想与大家分享这个宝藏教程请点击下方链接查看传送门https://blog.csdn.net/qq_74013365
返回列表