<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>并发编程 on zouyiyi 的技术笔记</title><link>https://blog.zouyiyi.top/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/</link><description>Recent content in 并发编程 on zouyiyi 的技术笔记</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><lastBuildDate>Fri, 13 Nov 2026 01:52:00 +0800</lastBuildDate><atom:link href="https://blog.zouyiyi.top/tags/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/index.xml" rel="self" type="application/rss+xml"/><item><title>Agent Harness 第一篇：浏览器断线后，任务为什么还能继续？</title><link>https://blog.zouyiyi.top/posts/agent-harness-background-turn/</link><pubDate>Fri, 13 Nov 2026 01:52:00 +0800</pubDate><guid>https://blog.zouyiyi.top/posts/agent-harness-background-turn/</guid><description>&lt;img src="https://blog.zouyiyi.top/posts/agent-harness-background-turn/cover.webp" alt="Featured image of post Agent Harness 第一篇：浏览器断线后，任务为什么还能继续？" /&gt;&lt;p&gt;一个 Agent 正在调用模型、执行脚本、生成文件。用户刷新页面，或者地铁里网络断了几秒：任务应该停止，还是继续？重新打开页面之后，应该重新执行，还是接着看原来的任务？&lt;/p&gt;&#10;&lt;p&gt;这看起来像一个流式接口问题，实际决定了 &lt;strong&gt;Agent 的执行生命周期由谁拥有&lt;/strong&gt;。&lt;/p&gt;&#10;&lt;p&gt;我的项目把一轮工作交给后台 &lt;code&gt;Turn&lt;/code&gt; 管理，HTTP/SSE 连接只订阅它的输出。因此，浏览器断连只结束订阅，任务可以继续执行；用户明确点击停止，才请求取消任务。&lt;/p&gt;&#10;&lt;p&gt;但这套设计有一个必须讲清楚的边界：&lt;strong&gt;它实现了单进程内的执行与连接解耦，并没有因此获得任意任务的跨进程恢复能力。&lt;/strong&gt; 最近的事件可以补播，已经落库的消息可以重读，特定的用户输入等待点可以重建执行；这些能力各有条件。&lt;/p&gt;&#10;&lt;p&gt;这篇文章只讨论这一个技术点：从“连接承载任务”走向“连接观察任务”，实现上需要解决什么，又能承诺到哪一步。&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;本文项目代码基线为 &lt;code&gt;4da2c8adc9a1319e39b1d9f058c5ec0d7add618e&lt;/code&gt;。外部资料核对日期为 &lt;strong&gt;2026 年 11 月 13 日&lt;/strong&gt;。厂商部分描述公开接口契约；涉及设计动机时会明确标为分析，不推测内部基础设施。&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;h2 id="1-为什么这是-harness-的问题"&gt;&lt;a href="#1-%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%99%e6%98%af-harness-%e7%9a%84%e9%97%ae%e9%a2%98" class="header-anchor"&gt;&lt;/a&gt;1. 为什么这是 Harness 的问题&#10;&lt;/h2&gt;&lt;p&gt;这里的 harness，可以理解为模型外面负责组织和约束执行的运行系统。模型输出下一步行动，harness 负责调用工具、保存结果、管理取消，并决定连接消失之后谁继续承担这轮工作的责任。&lt;/p&gt;&#10;&lt;p&gt;先看一种常见实现，下面是问题示意，并非项目当前代码：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;span class="lnt"&gt;4&#10;&lt;/span&gt;&lt;span class="lnt"&gt;5&#10;&lt;/span&gt;&lt;span class="lnt"&gt;6&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;handleChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 模型调用、工具执行都继承这次 HTTP 请求的生命周期。&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;writeSSE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;在 Go 服务端，请求的 Context 会在客户端连接关闭、HTTP/2 请求被取消或 &lt;code&gt;ServeHTTP&lt;/code&gt; 返回时取消。如果模型请求、工具执行、数据库操作都沿用它，取消就可能一路传播下去。&lt;a class="link" href="https://pkg.go.dev/net/http#Request.Context" target="_blank" rel="noopener"&#10; &gt;Go：Request.Context&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;对于一次普通查询，这通常合理；对于已经开始产生外部副作用的 Agent 工作流，需要重新选择语义。例如，脚本已经写好了文件，网络随后中断。如果前端把“没收到结果”理解成“没有执行”，再提交一次，可能造成重复工作。&lt;/p&gt;&#10;&lt;p&gt;即使改成 WebSocket，这个问题也仍然存在。传输协议决定如何交换数据，执行所有权决定连接断开后由谁继续工作。&lt;/p&gt;&#10;&lt;p&gt;因此，先区分四种保证：&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;保证&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;真正要回答的问题&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;执行独立&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;观察者断线以后，原任务是否仍然运行？&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;视图恢复&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;重新打开页面，能否重建已经产生的输出？&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;执行恢复&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;执行进程死亡以后，能否从明确边界继续？&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;副作用正确性&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;重试以后，写文件、调用外部 API 等动作是否会重复？&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;“支持断线重连”没有说明后三项的答案。接口设计、测试和面试表达，都应该把这四种保证分开。&lt;/p&gt;&#10;&lt;h2 id="2-当前项目turn-拥有执行连接拥有订阅"&gt;&lt;a href="#2-%e5%bd%93%e5%89%8d%e9%a1%b9%e7%9b%aeturn-%e6%8b%a5%e6%9c%89%e6%89%a7%e8%a1%8c%e8%bf%9e%e6%8e%a5%e6%8b%a5%e6%9c%89%e8%ae%a2%e9%98%85" class="header-anchor"&gt;&lt;/a&gt;2. 当前项目：Turn 拥有执行，连接拥有订阅&#10;&lt;/h2&gt;&lt;p&gt;项目里有三个不同寿命的对象：&lt;code&gt;Session&lt;/code&gt; 保存多轮会话，&lt;code&gt;Turn&lt;/code&gt; 表示其中一轮后台执行，SSE 订阅表示某个客户端正在观察它。同一个 Turn 可以经历多次订阅，也可以暂时没有订阅者。&lt;/p&gt;&#10;&lt;p&gt;&lt;img alt="Background Turn 独立执行，浏览器通过 SSE 订阅有界事件缓冲，消息另行持久化" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://blog.zouyiyi.top/posts/agent-harness-background-turn/architecture.svg"&gt;&lt;/p&gt;&#10;&lt;p&gt;核心入口是 &lt;code&gt;TurnRegistry.Start&lt;/code&gt;。下面节选关键结构，省略了部分初始化与删除保护代码：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 2&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 3&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 4&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 5&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 6&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 7&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 8&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 9&#10;&lt;/span&gt;&lt;span class="lnt"&gt;10&#10;&lt;/span&gt;&lt;span class="lnt"&gt;11&#10;&lt;/span&gt;&lt;span class="lnt"&gt;12&#10;&lt;/span&gt;&lt;span class="lnt"&gt;13&#10;&lt;/span&gt;&lt;span class="lnt"&gt;14&#10;&lt;/span&gt;&lt;span class="lnt"&gt;15&#10;&lt;/span&gt;&lt;span class="lnt"&gt;16&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Running&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ErrTurnRunning&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithCancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;Turn&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;streamID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;chan&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}),&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sessionID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;这里包含两个设计决定。&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;第一，任务 Context 不从 HTTP 请求派生。&lt;/strong&gt; &lt;code&gt;Start&lt;/code&gt; 创建独立 Context，把取消句柄交给 Turn 管理。API 层随后显式注入数据库依赖。HTTP 请求自己的 Context 只传给 &lt;code&gt;Follow&lt;/code&gt;，用于终止本次订阅。&lt;/p&gt;&#10;&lt;p&gt;这也意味着，独立 Context 不能自动继承请求中的所有值。认证、租户权限需要在入口完成校验，后台需要的依赖与执行身份要明确传递。未来如果增加追踪信息，也应显式传入，而不是把整个请求生命周期重新绑回来。&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;第二，同一个 Session 同时只允许一个正在运行的 Turn。&lt;/strong&gt; 检查和登记都发生在注册表锁内，避免两个并发请求同时通过检查。历史加载也在被接纳的后台工作里执行，减少两轮同时读写同一份历史的机会。&lt;/p&gt;&#10;&lt;p&gt;为什么当前选择拒绝，而不是自动排队？从实现取舍看，排队还需要定义用户后续消息究竟是“下一轮输入”“修改当前任务”还是“取消后的替代任务”，并处理排队期间的历史变化。先限制单会话并发，能把时序语义保持清楚。代价是暂不支持同一会话同时运行多个独立 Turn；同一 Turn 内仍可以有并发工具或子 Agent。&lt;/p&gt;&#10;&lt;p&gt;需要注意，这把锁只保护本进程。部署两个实例，每个实例各有一张 &lt;code&gt;turns&lt;/code&gt; 表，并不会自然形成全局互斥。&lt;/p&gt;&#10;&lt;h2 id="3-补播协议游标为什么需要两个字段"&gt;&lt;a href="#3-%e8%a1%a5%e6%92%ad%e5%8d%8f%e8%ae%ae%e6%b8%b8%e6%a0%87%e4%b8%ba%e4%bb%80%e4%b9%88%e9%9c%80%e8%a6%81%e4%b8%a4%e4%b8%aa%e5%ad%97%e6%ae%b5" class="header-anchor"&gt;&lt;/a&gt;3. 补播协议：游标为什么需要两个字段&#10;&lt;/h2&gt;&lt;p&gt;任务开始之后，事件写入 Turn 的内存缓冲，再由订阅者读取。当前默认保留最近 &lt;strong&gt;3000 个事件&lt;/strong&gt;。&lt;/p&gt;&#10;&lt;p&gt;首次发送消息走 POST；断线后重新订阅走 GET：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;span class="lnt"&gt;4&#10;&lt;/span&gt;&lt;span class="lnt"&gt;5&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /api/sessions/{session_id}/messages&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /api/sessions/{session_id}/stream&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ?from_seq=43&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;amp;stream_id=本轮事件流的UUID&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;假设客户端已经处理了第 42 号事件，就从 43 开始继续。项目的 &lt;code&gt;from_seq&lt;/code&gt; 是“下一条要读取的序号”，不是“最后读到的序号”。事件从 0 开始编号，SSE 帧大致如下：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;id: 42&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;type&amp;#34;:&amp;#34;delta&amp;#34;,&amp;#34;content&amp;#34;:&amp;#34;继续生成的内容&amp;#34;,&amp;#34;stream_id&amp;#34;:&amp;#34;本轮UUID&amp;#34;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;单独一个 &lt;code&gt;seq&lt;/code&gt; 不够。同一 Session 的下一轮，以及输入等待点恢复后新建的 worker，都会产生新的事件流；如果还拿旧流的 200 号游标去读新流，可能跳过新输出。&lt;/p&gt;&#10;&lt;p&gt;因此，真正的游标是：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(stream_id, seq)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;&lt;code&gt;stream_id&lt;/code&gt; 标识一轮具体的事件流，&lt;code&gt;seq&lt;/code&gt; 才在该流内部表示位置。服务端发现流身份变化时把读取位置重置为 0；输入等待点的恢复还会发出 &lt;code&gt;stream_reset&lt;/code&gt;，让前端重建临时展示。&lt;/p&gt;&#10;&lt;h3 id="只重接订阅不自动重发任务"&gt;&lt;a href="#%e5%8f%aa%e9%87%8d%e6%8e%a5%e8%ae%a2%e9%98%85%e4%b8%8d%e8%87%aa%e5%8a%a8%e9%87%8d%e5%8f%91%e4%bb%bb%e5%8a%a1" class="header-anchor"&gt;&lt;/a&gt;只重接订阅，不自动重发任务&#10;&lt;/h3&gt;&lt;p&gt;前端 &lt;code&gt;followStream&lt;/code&gt; 的自动重试只重新 GET 订阅，不重新发送创建、编辑或重新生成消息的 POST。&lt;/p&gt;&#10;&lt;p&gt;这是为了处理一个很现实的状态：&lt;strong&gt;客户端没有收到确认，但服务端可能已经接纳了任务。&lt;/strong&gt; 此时重复创建，可能重复调用模型和工具。&lt;/p&gt;&#10;&lt;p&gt;当前还有一个保守分支：首次 POST 尚未确认 SSE 已建立就断开时，前端不会盲目接入 Session 上残留的旧 Turn，而是报告连接中断。这减少了误接上一轮的风险，但不能提供“提交一定成功或一定失败”的结论。&lt;/p&gt;&#10;&lt;p&gt;这个缺口需要任务提交幂等协议来进一步解决，例如给一次逻辑提交保存稳定的 &lt;code&gt;request_id&lt;/code&gt;，由服务端返回同一个执行对象。当前普通消息创建接口还没有这样的幂等键，不能把“不自动重发”写成“已经实现 exactly-once”。&lt;/p&gt;&#10;&lt;h3 id="游标什么时候前移"&gt;&lt;a href="#%e6%b8%b8%e6%a0%87%e4%bb%80%e4%b9%88%e6%97%b6%e5%80%99%e5%89%8d%e7%a7%bb" class="header-anchor"&gt;&lt;/a&gt;游标什么时候前移&#10;&lt;/h3&gt;&lt;p&gt;网络分块不等于 SSE 事件边界。&lt;code&gt;id:&lt;/code&gt; 和 &lt;code&gt;data:&lt;/code&gt; 可能落在两次读取中，JSON 也可能只收到一半。&lt;/p&gt;&#10;&lt;p&gt;当前解析器保留跨分块的 &lt;code&gt;pendingSeq&lt;/code&gt;；只有 JSON 成功解析、事件回调执行之后，才更新序号。因此，“读到了 id 行”不等于“已消费这个事件”。这里的消费确认属于前端本地逻辑，不是浏览器持久存储成功的事务确认。&lt;/p&gt;&#10;&lt;h2 id="4-最值得推敲的并发细节历史与实时之间不能有空窗"&gt;&lt;a href="#4-%e6%9c%80%e5%80%bc%e5%be%97%e6%8e%a8%e6%95%b2%e7%9a%84%e5%b9%b6%e5%8f%91%e7%bb%86%e8%8a%82%e5%8e%86%e5%8f%b2%e4%b8%8e%e5%ae%9e%e6%97%b6%e4%b9%8b%e9%97%b4%e4%b8%8d%e8%83%bd%e6%9c%89%e7%a9%ba%e7%aa%97" class="header-anchor"&gt;&lt;/a&gt;4. 最值得推敲的并发细节：历史与实时之间不能有空窗&#10;&lt;/h2&gt;&lt;p&gt;实现补播时，很容易写出这样的逻辑：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1. 读取历史，发现已经追平。&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2. 解锁。&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3. 注册通知，等待下一条事件。&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;如果生产者恰好在第 2 步和第 3 步之间写入事件并发出通知，订阅者可能错过通知。数据已经在缓冲里，订阅者却还在等待。&lt;/p&gt;&#10;&lt;p&gt;项目用同一把 &lt;code&gt;t.mu&lt;/code&gt; 协调“检查事件”和“取得通知通道”。下面是 &lt;code&gt;Follow&lt;/code&gt; 的核心流程，省略了旧游标钳位与心跳：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 2&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 3&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 4&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 5&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 6&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 7&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 8&#10;&lt;/span&gt;&lt;span class="lnt"&gt; 9&#10;&lt;/span&gt;&lt;span class="lnt"&gt;10&#10;&lt;/span&gt;&lt;span class="lnt"&gt;11&#10;&lt;/span&gt;&lt;span class="lnt"&gt;12&#10;&lt;/span&gt;&lt;span class="lnt"&gt;13&#10;&lt;/span&gt;&lt;span class="lnt"&gt;14&#10;&lt;/span&gt;&lt;span class="lnt"&gt;15&#10;&lt;/span&gt;&lt;span class="lnt"&gt;16&#10;&lt;/span&gt;&lt;span class="lnt"&gt;17&#10;&lt;/span&gt;&lt;span class="lnt"&gt;18&#10;&lt;/span&gt;&lt;span class="lnt"&gt;19&#10;&lt;/span&gt;&lt;span class="lnt"&gt;20&#10;&lt;/span&gt;&lt;span class="lnt"&gt;21&#10;&lt;/span&gt;&lt;span class="lnt"&gt;22&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 网络写发生在锁外&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;finished&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finishedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 与“已追平”的判断处于同一个临界区&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;finished&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;生产者在同一把锁内追加事件，随后关闭旧通道，并创建新的通道：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;span class="lnt"&gt;4&#10;&lt;/span&gt;&lt;span class="lnt"&gt;5&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 持有 t.mu 时调用&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Turn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;wakeLocked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wake&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;chan&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;&lt;img alt="Follow 与 append 的两种时序：同一把锁保证看到数据或取得会被关闭的通知通道" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://blog.zouyiyi.top/posts/agent-harness-background-turn/replay-race.svg"&gt;&lt;/p&gt;&#10;&lt;p&gt;可以按生产者发生的时间来证明：&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;生产者先获得锁。&lt;/strong&gt; 新事件已写入，订阅者随后检查缓冲，就会看到未读数据。&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;订阅者先获得锁。&lt;/strong&gt; 它在确认已追平的同时取得旧 &lt;code&gt;wake&lt;/code&gt;。生产者随后关闭这个通道；即使订阅者还没进入 &lt;code&gt;select&lt;/code&gt;，读取已关闭的通道也会立即就绪。&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;这里的 channel 只表示“状态发生变化”，不承载事件内容，也不要求一个通知对应一个事件。唤醒后重新检查缓冲即可。关闭通道能唤醒所有正在等待的订阅者，每个订阅者维护自己的读取位置。&lt;/p&gt;&#10;&lt;p&gt;这和让多个订阅者从一个 &lt;code&gt;chan Event&lt;/code&gt; 竞争消费不同：后者通常会把事件分给其中一个读者，无法直接实现每个页面都看到同一份输出。&lt;/p&gt;&#10;&lt;p&gt;另外，&lt;code&gt;onEvent&lt;/code&gt; 在锁外执行。慢客户端的网络写不会长期占住生产者的锁；生产者只负责追加和唤醒。代价是慢客户端可能落后到保留窗口之外。&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;这个证明说明的是“不丢唤醒”，没有证明“不丢历史数据”。&lt;/strong&gt; 两者分别由并发协议和保留策略决定。&lt;/p&gt;&#10;&lt;h2 id="5-有界缓冲的代价可以补播多久"&gt;&lt;a href="#5-%e6%9c%89%e7%95%8c%e7%bc%93%e5%86%b2%e7%9a%84%e4%bb%a3%e4%bb%b7%e5%8f%af%e4%bb%a5%e8%a1%a5%e6%92%ad%e5%a4%9a%e4%b9%85" class="header-anchor"&gt;&lt;/a&gt;5. 有界缓冲的代价：可以补播多久&#10;&lt;/h2&gt;&lt;p&gt;当前缓冲使用切片追加，超过上限就移出最旧事件，并增加 &lt;code&gt;dropped&lt;/code&gt;。源码注释中出现过“环形缓冲”的称呼，但实现并不是预分配数组上的循环下标；更准确的称呼是“有界滑动事件缓冲”。&lt;/p&gt;&#10;&lt;p&gt;其序号关系很简单：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;events[i] 的真实序号 = dropped + i&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;下一条事件的序号 = dropped + len(events)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;当客户端的 &lt;code&gt;from_seq&lt;/code&gt; 小于 &lt;code&gt;dropped&lt;/code&gt; 时，当前实现直接把它钳到最早可用位置。&lt;strong&gt;它没有额外发送 gap 事件，也没有在这一步自动返回完整快照。&lt;/strong&gt; 因此，不能承诺任意长时间断线后都能还原完整的流式过程。&lt;/p&gt;&#10;&lt;p&gt;3000 是事件数，不是字节数，也不是固定时长。用一个仅供理解的算例：如果平均每秒产生 30 个事件，3000 条约覆盖 100 秒；如果变成每秒 300 个事件，就只覆盖约 10 秒。这不是项目实测吞吐，真实窗口取决于事件产生速度与负载。&lt;/p&gt;&#10;&lt;p&gt;内存成本也取决于载荷。一个很小的文本 delta 和一个很大的工具结果，都各算一个事件。即使条数相同，字节占用也可能差很多。&lt;/p&gt;&#10;&lt;p&gt;项目还定义了完成后 5 分钟的清理阈值。但清理只在下一次 &lt;code&gt;Start&lt;/code&gt; 时顺带执行；同一 Session 的新 Turn 也会覆盖旧 Turn。因此不能把它描述成“每轮保证保留 5 分钟”或“到了 5 分钟一定释放”。&lt;/p&gt;&#10;&lt;p&gt;这套选择的收益是实现简单、无需额外事件存储，适合当前单实例运行。后续如果需要明确的断线服务保证，可以增加以下协议，&lt;strong&gt;这些属于改进方向，尚不是现有能力&lt;/strong&gt;：&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;返回 &lt;code&gt;earliest_seq&lt;/code&gt;，检测到窗口缺口时显式通知客户端重建视图。&lt;/li&gt;&#10;&lt;li&gt;以已保存消息重建快照，再接入实时流，并定义去重规则。&lt;/li&gt;&#10;&lt;li&gt;同时设置事件数量和字节预算；需要长时间补播时使用持久事件存储。&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;引入快照之后，也必须解决上一节的历史与订阅空窗，不能简单地“先 GET 历史，再连 SSE”。&lt;/p&gt;&#10;&lt;h2 id="6-事件展示持久化与取消分别承诺什么"&gt;&lt;a href="#6-%e4%ba%8b%e4%bb%b6%e5%b1%95%e7%a4%ba%e6%8c%81%e4%b9%85%e5%8c%96%e4%b8%8e%e5%8f%96%e6%b6%88%e5%88%86%e5%88%ab%e6%89%bf%e8%af%ba%e4%bb%80%e4%b9%88" class="header-anchor"&gt;&lt;/a&gt;6. 事件展示、持久化与取消，分别承诺什么&#10;&lt;/h2&gt;&lt;h3 id="用户看到结果不等于结果已经提交数据库"&gt;&lt;a href="#%e7%94%a8%e6%88%b7%e7%9c%8b%e5%88%b0%e7%bb%93%e6%9e%9c%e4%b8%8d%e7%ad%89%e4%ba%8e%e7%bb%93%e6%9e%9c%e5%b7%b2%e7%bb%8f%e6%8f%90%e4%ba%a4%e6%95%b0%e6%8d%ae%e5%ba%93" class="header-anchor"&gt;&lt;/a&gt;用户看到结果，不等于结果已经提交数据库&#10;&lt;/h3&gt;&lt;p&gt;当前项目的模型 delta 即时发送，普通工具执行后的顺序也大致是：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;span class="lnt"&gt;3&#10;&lt;/span&gt;&lt;span class="lnt"&gt;4&#10;&lt;/span&gt;&lt;span class="lnt"&gt;5&#10;&lt;/span&gt;&lt;span class="lnt"&gt;6&#10;&lt;/span&gt;&lt;span class="lnt"&gt;7&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;工具执行完成&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;emit(tool_call_result / tool_call_error)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;广播工件信息&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ↓&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;saveMessage(...)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;也就是说，SSE 是实时展示流，不是提交之后才发布的持久化事件日志。进程如果在 &lt;code&gt;emit&lt;/code&gt; 和 &lt;code&gt;saveMessage&lt;/code&gt; 之间崩溃，用户可能已经看到结果，但数据库还没有对应记录。&lt;/p&gt;&#10;&lt;p&gt;前端在轮次收尾后会重新获取 Session，以已保存消息替换临时流式状态。这是一种展示收敛机制，能够校准已经落库的内容；它不能补回从未成功写入数据库的事实。&lt;/p&gt;&#10;&lt;p&gt;“持久化后再发布”可以缩小这一窗口，但也会把存储延迟带入展示链路。对逐 token 增量和对业务结果，不一定需要采用完全相同的可靠性级别。厂商方案中也能看到这种分层。&lt;/p&gt;&#10;&lt;h3 id="停止是取消请求结束标记也不等于成功"&gt;&lt;a href="#%e5%81%9c%e6%ad%a2%e6%98%af%e5%8f%96%e6%b6%88%e8%af%b7%e6%b1%82%e7%bb%93%e6%9d%9f%e6%a0%87%e8%ae%b0%e4%b9%9f%e4%b8%8d%e7%ad%89%e4%ba%8e%e6%88%90%e5%8a%9f" class="header-anchor"&gt;&lt;/a&gt;停止是取消请求，结束标记也不等于成功&#10;&lt;/h3&gt;&lt;p&gt;浏览器断开，只取消 &lt;code&gt;Follow&lt;/code&gt;。显式停止接口则先处理等待输入的停止事实，再调用 &lt;code&gt;Abort&lt;/code&gt; 取消 Turn，并通知等待者。&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;Abort&lt;/code&gt; 是协作式取消：worker、模型调用和工具需要响应 Context。返回“停止请求已接受”，不代表所有子过程已经退出，更不代表已经发生的文件写入或外部 API 调用被回滚。&lt;/p&gt;&#10;&lt;p&gt;项目的 &lt;code&gt;saveMessage&lt;/code&gt; 使用 &lt;code&gt;context.WithoutCancel&lt;/code&gt;，避免收尾落库立刻继承任务取消。Go 对这个函数的定义是移除父 Context 的取消与截止时间约束，同时保留其值；它不会让代码跨进程死亡继续运行。&lt;a class="link" href="https://pkg.go.dev/context#WithoutCancel" target="_blank" rel="noopener"&#10; &gt;Go：WithoutCancel&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;当前这条持久化路径没有再附加独立超时。后续可考虑在脱离执行取消之后，设置一个有限的收尾时间，并记录失败，这样才能兼顾“尽量保存”和“不会无限等待”。&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;pump&lt;/code&gt; 还会在错误或 panic 路径补齐终止事件，并设置完成时间。这里的 &lt;code&gt;done&lt;/code&gt; 表示流已经收尾；失败路径也可以出现 &lt;code&gt;done&lt;/code&gt;。前端判断成功时必须结合错误与结果，不能只看到结束标记就展示成功。&lt;/p&gt;&#10;&lt;h2 id="7-服务重启以后只恢复有明确定义的等待点"&gt;&lt;a href="#7-%e6%9c%8d%e5%8a%a1%e9%87%8d%e5%90%af%e4%bb%a5%e5%90%8e%e5%8f%aa%e6%81%a2%e5%a4%8d%e6%9c%89%e6%98%8e%e7%a1%ae%e5%ae%9a%e4%b9%89%e7%9a%84%e7%ad%89%e5%be%85%e7%82%b9" class="header-anchor"&gt;&lt;/a&gt;7. 服务重启以后：只恢复有明确定义的等待点&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;TurnRegistry&lt;/code&gt;、缓冲和取消句柄都在内存。服务进程死亡后，它们一起消失。后台 goroutine 解决的是与请求解耦，没有把程序计数器保存到数据库。&lt;/p&gt;&#10;&lt;p&gt;当前项目有一个有意设计的例外：&lt;code&gt;request_input&lt;/code&gt; 等待用户回答。&lt;/p&gt;&#10;&lt;p&gt;问题与答案保存在 &lt;code&gt;messages&lt;/code&gt; 中。回答先提交数据库，再通知 worker；等待者还有每两秒查询结果的兜底，因此通知丢失并不必然丢失答案。服务重启后，重新订阅或提交回答的路径可以识别这个等待点，重建 worker，并使用新的事件流身份继续。&lt;/p&gt;&#10;&lt;p&gt;它之所以能恢复，是因为暂停边界有业务定义：知道在等哪个问题、是否已有答案、接下来如何继续。同批次中其他普通工具会被标记为“未执行”，恢复时不重放它们的副作用。这里不展开输入协议，只强调：&lt;strong&gt;恢复点需要保存“接下来该做什么”的语义，只有一段聊天历史还不够。&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;如果进程在任意 &lt;code&gt;bash&lt;/code&gt;、文件修改或外部工具调用中死亡，当前没有通用检查点来恢复原执行。历史组装时给缺失的工具结果补占位，可以修复消息协议配对，却不能推断工具究竟成功了没有。&lt;/p&gt;&#10;&lt;p&gt;这一边界也决定了之后如何升级：应该先识别允许重试的工作单元与副作用，再选择持久化机制。&lt;/p&gt;&#10;&lt;h2 id="8-横向对比主流方案把恢复责任放在哪里"&gt;&lt;a href="#8-%e6%a8%aa%e5%90%91%e5%af%b9%e6%af%94%e4%b8%bb%e6%b5%81%e6%96%b9%e6%a1%88%e6%8a%8a%e6%81%a2%e5%a4%8d%e8%b4%a3%e4%bb%bb%e6%94%be%e5%9c%a8%e5%93%aa%e9%87%8c" class="header-anchor"&gt;&lt;/a&gt;8. 横向对比：主流方案把恢复责任放在哪里&#10;&lt;/h2&gt;&lt;p&gt;下面比较的是公开产品或框架接口，而不是各家未公开的内部架构。特别是同一厂商的模型 API 与托管 Agent API，可能有不同的事件恢复契约。&lt;/p&gt;&#10;&lt;h3 id="openaibackground-response-与托管-agent-session-是两层能力"&gt;&lt;a href="#openaibackground-response-%e4%b8%8e%e6%89%98%e7%ae%a1-agent-session-%e6%98%af%e4%b8%a4%e5%b1%82%e8%83%bd%e5%8a%9b" class="header-anchor"&gt;&lt;/a&gt;OpenAI：Background Response 与托管 Agent Session 是两层能力&#10;&lt;/h3&gt;&lt;p&gt;Responses 的 background mode 允许异步运行模型响应，客户端可以轮询或取消。创建时同时开启流式输出后，可记住 &lt;code&gt;sequence_number&lt;/code&gt;，通过 &lt;code&gt;starting_after&lt;/code&gt; 重新接流。这体现了“执行对象独立于监听连接”。它没有替应用提供任意自定义工具工作流的崩溃恢复；函数调用中的本地工具仍需要应用执行并回传结果。&lt;a class="link" href="https://developers.openai.com/api/docs/guides/background" target="_blank" rel="noopener"&#10; &gt;Background mode&lt;/a&gt;、&lt;a class="link" href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener"&#10; &gt;Function calling&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;OpenAI 当前的 Agents API 则以 Session 和异步 Turn 管理工作，区分实时 events 与保存的 items。其流不重放错过的事件；官方恢复流程是先开新流并缓冲，再取 Session 与 saved items，按 item 身份重建并合并。对已终结的 item，丢弃过时更新。完整输出可以修正临时 delta，但无法还原所有中间事件。&lt;a class="link" href="https://developers.openai.com/api/docs/guides/agents-api/sessions" target="_blank" rel="noopener"&#10; &gt;Run and continue sessions&lt;/a&gt;、&lt;a class="link" href="https://developers.openai.com/api/docs/guides/agents-api/sessions/events" target="_blank" rel="noopener"&#10; &gt;Events and items&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;**设计分析：**前一种契约接近有游标的响应流；后一种把页面恢复建立在保存的工作结果上。它们支持的抽象层次不同，不能把 Responses 的补播参数直接套到 Agents API，也不能把一次模型响应的后台运行解释成整个应用已经获得 durable execution。&lt;/p&gt;&#10;&lt;h3 id="anthropic持久事件与逐步预览分层"&gt;&lt;a href="#anthropic%e6%8c%81%e4%b9%85%e4%ba%8b%e4%bb%b6%e4%b8%8e%e9%80%90%e6%ad%a5%e9%a2%84%e8%a7%88%e5%88%86%e5%b1%82" class="header-anchor"&gt;&lt;/a&gt;Anthropic：持久事件与逐步预览分层&#10;&lt;/h3&gt;&lt;p&gt;Claude Managed Agents 的新 stream 默认只接建立后的事件。官方给出的重连顺序是：先订阅并缓冲，读取事件历史，再按事件 ID 去重后接上实时；显式中断使用 &lt;code&gt;user.interrupt&lt;/code&gt;，入队也不意味着立即停止。&lt;a class="link" href="https://platform.claude.com/docs/en/managed-agents/events-and-streaming" target="_blank" rel="noopener"&#10; &gt;Session event stream&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;它进一步区分完整的 &lt;code&gt;agent.message&lt;/code&gt; 与 &lt;code&gt;event_start&lt;/code&gt;、&lt;code&gt;event_delta&lt;/code&gt; 预览。预览是尽力提供的，不作为持久历史补播；重连后应该用完成的消息校正临时内容。&lt;a class="link" href="https://platform.claude.com/docs/en/managed-agents/event-deltas" target="_blank" rel="noopener"&#10; &gt;Preview responses with event deltas&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;**设计分析：**不必把打字动画的每一个增量都提升成永久事实。完整消息承担恢复职责，临时增量承担即时体验。相应代价是无法保证重现断线期间的逐 token 动画。客户端必须具备“预览被完整结果替换”的能力。&lt;/p&gt;&#10;&lt;p&gt;另一个容易混淆的入口是 Claude Agent SDK 的 &lt;code&gt;resume&lt;/code&gt;：它从保存的 session 恢复会话上下文，默认使用本地会话存储。它本身不意味着恢复原来的 SSE 连接、任意指令位置或外部文件快照。把 SDK 包成 Web 服务后，执行托管与浏览器重连协议仍需要应用设计。&lt;a class="link" href="https://code.claude.com/docs/en/agent-sdk/sessions" target="_blank" rel="noopener"&#10; &gt;Work with sessions&lt;/a&gt;&lt;/p&gt;&#10;&lt;h3 id="langgraph图检查点负责执行恢复流接口负责观察"&gt;&lt;a href="#langgraph%e5%9b%be%e6%a3%80%e6%9f%a5%e7%82%b9%e8%b4%9f%e8%b4%a3%e6%89%a7%e8%a1%8c%e6%81%a2%e5%a4%8d%e6%b5%81%e6%8e%a5%e5%8f%a3%e8%b4%9f%e8%b4%a3%e8%a7%82%e5%af%9f" class="header-anchor"&gt;&lt;/a&gt;LangGraph：图检查点负责执行恢复，流接口负责观察&#10;&lt;/h3&gt;&lt;p&gt;Agent Server 可以独立创建后台 run。流恢复需要看具体接口：官方 thread stream 支持 &lt;code&gt;Last-Event-ID&lt;/code&gt;，但普通 &lt;code&gt;runs.join_stream&lt;/code&gt; 的指南仍说明不会补齐加入之前的输出。不能把“加入运行”统一理解为“完整重放”。&lt;a class="link" href="https://docs.langchain.com/langsmith/background-run" target="_blank" rel="noopener"&#10; &gt;Background runs&lt;/a&gt;、&lt;a class="link" href="https://docs.langchain.com/langsmith/streaming" target="_blank" rel="noopener"&#10; &gt;Streaming API&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;LangGraph 的执行恢复依靠 checkpointer 保存图状态，而不是依靠 SSE。&lt;code&gt;sync&lt;/code&gt; 持久化后再前进，&lt;code&gt;async&lt;/code&gt; 与后续执行并行，&lt;code&gt;exit&lt;/code&gt; 到退出时才保存；这些模式在开销与中途崩溃窗口之间作不同选择。跨重启恢复还需要持久存储后端，内存 saver 不具备这个条件。&lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/persistence" target="_blank" rel="noopener"&#10; &gt;Persistence&lt;/a&gt;、&lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/checkpointers" target="_blank" rel="noopener"&#10; &gt;Checkpointers&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;**设计分析：**明确的图步骤提供了可保存、可重试的边界，代价是业务需要按这些边界组织状态和执行。检查点可以恢复“运行到了哪里”，外部副作用依然需要另外处理。&lt;/p&gt;&#10;&lt;h3 id="放在同一张表里比较"&gt;&lt;a href="#%e6%94%be%e5%9c%a8%e5%90%8c%e4%b8%80%e5%bc%a0%e8%a1%a8%e9%87%8c%e6%af%94%e8%be%83" class="header-anchor"&gt;&lt;/a&gt;放在同一张表里比较&#10;&lt;/h3&gt;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;方案&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;执行由谁持有&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;断线后恢复什么&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;不能直接推出的保证&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;本项目 Background Turn&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;单进程注册表与 worker&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;缓冲内事件；已保存消息；特定输入等待点&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;任意长补播、任意任务跨进程续跑&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;OpenAI Background Responses&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;服务端 Response&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;对已开启流的后台响应按序号续接&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;应用自定义工具工作流自动恢复&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;OpenAI Agents API&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;托管 Session / Turn&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;saved items 与实时流合并&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;错过的所有中间事件都能补播&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Managed Agents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;托管 Session / harness&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;事件历史与实时流去重衔接&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;逐 token 预览永久保存&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Claude Agent SDK&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;应用托管的 SDK 运行环境&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;保存的会话上下文&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;浏览器重连协议自动完成&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;LangGraph + Agent Server&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;图运行时与 run&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;具体流接口的游标；检查点中的图状态&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;外部副作用天然只执行一次&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;表中的事实依据分别见上面各方案的官方链接；这是恢复语义比较，不是性能排名。&lt;/p&gt;&#10;&lt;p&gt;对当前项目最直接的启发有两点。其一，&lt;strong&gt;展示层可以有临时数据，但恢复必须有可信的事实来源&lt;/strong&gt;。其二，&lt;strong&gt;读取历史和订阅实时数据之间必须有衔接协议&lt;/strong&gt;：项目在进程内用同一把锁保证观察一致；OpenAI Agents API 与 Claude Managed Agents 则要求客户端先订阅，再读取保存的状态或历史，最后按对象身份合并或去重。这些方案解决了相似的空窗问题，但并不代表厂商内部使用了与项目相同的锁实现。&lt;/p&gt;&#10;&lt;h2 id="9-面试中如何把设计讲到边界"&gt;&lt;a href="#9-%e9%9d%a2%e8%af%95%e4%b8%ad%e5%a6%82%e4%bd%95%e6%8a%8a%e8%ae%be%e8%ae%a1%e8%ae%b2%e5%88%b0%e8%be%b9%e7%95%8c" class="header-anchor"&gt;&lt;/a&gt;9. 面试中如何把设计讲到边界&#10;&lt;/h2&gt;&lt;p&gt;介绍这一设计时，可以先用一段话建立主线：&lt;/p&gt;&#10;&#10; &lt;blockquote&gt;&#10; &lt;p&gt;项目把一轮 Agent 执行托管给独立的 Background Turn，HTTP/SSE 只订阅输出。Turn 维护有界事件缓冲，前端用流身份和序号重接；检查缓冲与取得唤醒通道在同一个临界区里，避免丢唤醒。断连只取消订阅，停止接口才请求取消执行。目前保证限定在单进程，通用跨进程恢复与外部副作用幂等还需要额外设计。&lt;/p&gt;&#10;&#10; &lt;/blockquote&gt;&#10;&lt;p&gt;接下来的追问通常会落到下面几个故障场景。&lt;/p&gt;&#10;&lt;h3 id="场景一工具已经成功保存结果前进程崩溃"&gt;&lt;a href="#%e5%9c%ba%e6%99%af%e4%b8%80%e5%b7%a5%e5%85%b7%e5%b7%b2%e7%bb%8f%e6%88%90%e5%8a%9f%e4%bf%9d%e5%ad%98%e7%bb%93%e6%9e%9c%e5%89%8d%e8%bf%9b%e7%a8%8b%e5%b4%a9%e6%ba%83" class="header-anchor"&gt;&lt;/a&gt;场景一：工具已经成功，保存结果前进程崩溃&#10;&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;外部操作成功 ──→ 进程崩溃 ──→ 数据库没有结果 ──→ 恢复后准备重试&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;仅凭数据库没有结果，不能判定外部动作没发生。这个状态应该视为“结果未知”。LangGraph 的故障容错文档也要求在重试边界考虑幂等，不能仅依靠节点恢复保证副作用正确。&lt;a class="link" href="https://docs.langchain.com/oss/python/langgraph/fault-tolerance#retry-boundaries-and-idempotency" target="_blank" rel="noopener"&#10; &gt;Fault tolerance：Retry boundaries and idempotency&lt;/a&gt;&lt;/p&gt;&#10;&lt;p&gt;后续设计可以为逻辑动作保存稳定的 execution ID，把它作为外部工具认可的幂等键，并支持按键查询结果。没有幂等能力的系统，可能需要人工确认或补偿。每次重试重新生成一个 UUID，会失去防重作用。&lt;/p&gt;&#10;&lt;p&gt;事务 Outbox 可以协调“数据库提交”与“事件发布”，但它不能单独把外部系统的动作和本地数据库变成一个原子事务。回答到这里，才算解释清楚“消息可靠”与“业务动作不重复”的区别。&lt;/p&gt;&#10;&lt;h3 id="场景二扩成多个实例重连命中了另一台机器"&gt;&lt;a href="#%e5%9c%ba%e6%99%af%e4%ba%8c%e6%89%a9%e6%88%90%e5%a4%9a%e4%b8%aa%e5%ae%9e%e4%be%8b%e9%87%8d%e8%bf%9e%e5%91%bd%e4%b8%ad%e4%ba%86%e5%8f%a6%e4%b8%80%e5%8f%b0%e6%9c%ba%e5%99%a8" class="header-anchor"&gt;&lt;/a&gt;场景二：扩成多个实例，重连命中了另一台机器&#10;&lt;/h3&gt;&lt;p&gt;当前实例内的 &lt;code&gt;TurnRegistry&lt;/code&gt; 在另一台机器上不存在。粘性路由可以缓解重连路由问题，却无法处理原实例宕机；单独增加 Pub/Sub 可以转发实时通知，也不能自动补齐历史或转移执行所有权。&lt;/p&gt;&#10;&lt;p&gt;进一步的设计至少需要同时明确：执行记录保存在哪里、谁拥有当前执行、何时允许接管、旧执行者恢复后是否还能提交结果。&lt;/p&gt;&#10;&lt;p&gt;一种可讨论的方案是持久化 run 状态，使用租约与续租协调所有权，并为每次所有权变更增加 fencing token，让状态存储或受控写入端拒绝旧执行者的提交。它仍然依赖下游是否配合，不能凭一个 token 就约束所有外部 API。&lt;/p&gt;&#10;&lt;p&gt;这是当前方案的演进方向，不是已经完成的分布式能力。&lt;/p&gt;&#10;&lt;h3 id="场景三慢客户端一直追不上生产者"&gt;&lt;a href="#%e5%9c%ba%e6%99%af%e4%b8%89%e6%85%a2%e5%ae%a2%e6%88%b7%e7%ab%af%e4%b8%80%e7%9b%b4%e8%bf%bd%e4%b8%8d%e4%b8%8a%e7%94%9f%e4%ba%a7%e8%80%85" class="header-anchor"&gt;&lt;/a&gt;场景三：慢客户端一直追不上生产者&#10;&lt;/h3&gt;&lt;p&gt;本项目选择让生产者继续运行，让订阅者从共享缓冲读取。网络写不占生产者锁，但落后太多会越过窗口。&lt;/p&gt;&#10;&lt;p&gt;如果要求“不丢任何展示事件”，就需要支付更长保存、持久化或背压的成本；如果允许丢临时增量，则可以在缺口后读取完整消息。应该先定义产品需要恢复的是什么，再决定保存什么。&lt;/p&gt;&#10;&lt;h3 id="场景四既然落了消息为什么不能直接恢复所有任务"&gt;&lt;a href="#%e5%9c%ba%e6%99%af%e5%9b%9b%e6%97%a2%e7%84%b6%e8%90%bd%e4%ba%86%e6%b6%88%e6%81%af%e4%b8%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e8%83%bd%e7%9b%b4%e6%8e%a5%e6%81%a2%e5%a4%8d%e6%89%80%e6%9c%89%e4%bb%bb%e5%8a%a1" class="header-anchor"&gt;&lt;/a&gt;场景四：既然落了消息，为什么不能直接恢复所有任务&#10;&lt;/h3&gt;&lt;p&gt;消息记录描述已经观察到或保存下来的内容，不必然包含尚未完成的调用、外部操作是否成功，以及下一步是否安全重试。&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;request_input&lt;/code&gt; 能恢复，是因为它有明确等待对象、已保存答案和不重放兄弟工具的协议。将来增加其他恢复点，也需要同样明确的边界。直接重新喂给模型整段历史，是一次基于历史继续推理，不能自动等同于恢复原来的执行。&lt;/p&gt;&#10;&lt;h2 id="10-这次验证了什么"&gt;&lt;a href="#10-%e8%bf%99%e6%ac%a1%e9%aa%8c%e8%af%81%e4%ba%86%e4%bb%80%e4%b9%88" class="header-anchor"&gt;&lt;/a&gt;10. 这次验证了什么&#10;&lt;/h2&gt;&lt;p&gt;本次检查以实际代码为准，并运行了已有的针对性测试，没有修改 Agent 实现。&lt;/p&gt;&#10;&lt;p&gt;在 &lt;code&gt;backend&lt;/code&gt; 目录执行：&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;&#10;&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1&#10;&lt;/span&gt;&lt;span class="lnt"&gt;2&#10;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#10;&lt;td class="lntd"&gt;&#10;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; ./pkg/agent ./api &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -run &lt;span class="s1"&gt;&amp;#39;^(TestTurn|TestDelete|TestFailedDelete|TestInput)&amp;#39;&lt;/span&gt; -count&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;/div&gt;&lt;p&gt;两个包均通过。前端使用 Node 24.13.0 执行 &lt;code&gt;frontend/tests/stream.test.ts&lt;/code&gt;，37 个流处理测试全部通过。相关测试覆盖断连不取消 Turn、并发启动拒绝、补播与旧游标处理、取消和异常收尾，以及前端分块解析和只重接订阅等行为。&lt;/p&gt;&#10;&lt;p&gt;这些是使用内存或替身依赖的针对性测试，&lt;strong&gt;不是&lt;/strong&gt;真实模型调用、线上网络故障、强制杀进程或多实例容灾实验。本文没有据此给出吞吐、延迟或 exactly-once 的实测结论。&lt;/p&gt;&#10;&lt;p&gt;便于继续阅读的源码入口如下，路径均相对项目根目录：&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;文件&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;阅读重点&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;backend/pkg/agent/turn.go&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Start&lt;/code&gt;、&lt;code&gt;Follow&lt;/code&gt;、&lt;code&gt;wakeLocked&lt;/code&gt;、&lt;code&gt;Abort&lt;/code&gt;、&lt;code&gt;pump&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;backend/api/chat.go&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;startTurn&lt;/code&gt;、&lt;code&gt;relaySSE&lt;/code&gt;、&lt;code&gt;AttachSessionStream&lt;/code&gt;、停止接口&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;frontend/src/api/stream.ts&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;分块解析、游标更新、只重接 GET 的策略&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;frontend/src/components/ChatArea.tsx&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;refreshAfterTurn&lt;/code&gt; 与 &lt;code&gt;stream_reset&lt;/code&gt; 的视图处理&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;backend/pkg/agent/agent.go&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;saveMessage&lt;/code&gt; 与工具结果先发送、后保存的顺序&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;backend/pkg/agent/input.go&lt;/code&gt;、&lt;code&gt;backend/api/input.go&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;输入等待点的识别与 worker 重建&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;backend/dao/input.go&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;回答持久化、并发防重与混合工具批次屏障&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;这个技术点体现的设计价值，在于明确了执行与观察的责任：连接可以短暂存在，任务拥有独立生命周期；临时输出可以追求及时，恢复依靠明确保存的事实；取消可以被请求，副作用是否完成仍需单独确认。能够准确说出这些保证的边界，才有基础继续演进更可靠的 Agent Harness。&lt;/p&gt;&#10;</description></item></channel></rss>