<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>AI Agent on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/ai-agent/</link><description>Recent content in AI Agent on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/ai-agent/index.xml" rel="self" type="application/rss+xml"/><item><title>第二话｜怎么根据用户的提问找到相关的退货政策？</title><link>https://zh.renxinblog.cn/post/gyr-c02-embedding-topk/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyr-c02-embedding-topk/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyr-c02-embedding-topk-cover.png" alt="Featured image of post 第二话｜怎么根据用户的提问找到相关的退货政策？" /&gt;&lt;p&gt;“这双鞋七天内能退吗？”&lt;/p&gt;
&lt;p&gt;NovaShop 的资料里没有这句话。资料写的是：“NovaTrail X2 签收后 7 个自然日内可申请退货；商品需保持完好且配件齐全。”&lt;/p&gt;
&lt;p&gt;两句话说的是同一件事，字面上却对不上。如果助手只会查关键词，这个问题很可能直接被漏掉——用户不会觉得是“没搜到”，只会觉得这个助手不懂人话。&lt;/p&gt;
&lt;p&gt;这一话就把这次匹配拆开，看三件事：问题怎样变成数字，三条政策怎样被排出先后，以及“排第一”到底意味着什么。&lt;/p&gt;
&lt;h2 id="先别急着谈向量"&gt;先别急着谈向量
&lt;/h2&gt;&lt;p&gt;先准备三段极小的候选资料：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;标识&lt;/th&gt;
					&lt;th&gt;候选片段&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;return-policy&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;NovaTrail X2 签收后 7 个自然日内可申请退货；商品需保持完好且配件齐全。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;shipping-policy&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;NovaTrail X2 标准配送通常在付款后 1 至 3 个工作日发货，偏远地区时效可能延长。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;size-guide&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;NovaTrail X2 提供黑色 M 码与 L 码；下单前请参考尺码表确认脚长。&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;如果只做关键词匹配，“七天内能退”里的“退”，和资料里的“申请退货”，靠的是同一个意思，而不是同一个词。想接住这种说法，传统做法是在词表里补同义词规则——把“退”“寄回去”“不合适”都映射到“退货”上。&lt;/p&gt;
&lt;p&gt;规则不是没有价值。SKU、订单号这类精确标识，恰恰适合这种方式：用户原样报出“NS-X2-BLK-M”，关键词匹配又快又准。&lt;/p&gt;
&lt;p&gt;可客服问题远不止 SKU 和订单号。用户会说“能退吗”“不喜欢能不能寄回去”“尺码不合适怎么办”，把每种说法都写进词表，维护成本很快失控。&lt;/p&gt;
&lt;p&gt;Embedding 换了个思路：不比字符串，先把文本变成一串数字（向量），再比较两段文本在这个空间里离得近不近。阿里云百炼把 Embedding 定义为把文本等数据转换为数值向量，用于语义搜索等下游任务。&lt;a class="link" href="https://help.aliyun.com/zh/model-studio/embedding" target="_blank" rel="noopener"
 &gt;官方文档&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="系统比较的到底是什么"&gt;系统比较的到底是什么
&lt;/h2&gt;&lt;p&gt;候选政策可以提前编码、保存。用户发来问题时，系统只需要编码这一个问题，再拿它和候选向量逐个比较。&lt;/p&gt;
&lt;figure class="gallery-image"&gt;
 &lt;a class="image-link" href="https://zh.renxinblog.cn/images/gyr-c02-embedding-topk-question-to-topk.svg" data-pswp-width="1040" data-pswp-height="420" target="_blank"&gt;
 &lt;img src="https://zh.renxinblog.cn/images/gyr-c02-embedding-topk-question-to-topk.svg" width="1040" height="420" alt="用户提问、查询向量、候选片段向量、相似度计算、Top-K 到回答证据的直角流程图"&gt;
 &lt;/a&gt;
 &lt;figcaption&gt;检索先找“该看哪几段”，回答环节再决定怎样组织答案。&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;这里有三件事不能混：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;查询和候选片段必须用同一个 Embedding 模型编码，否则两者未必落在同一套表示空间里。&lt;/li&gt;
&lt;li&gt;参与比较的两条向量必须等长；维度不同，余弦相似度无从算起。&lt;/li&gt;
&lt;li&gt;得到的只是当前候选集合里的排序，不是“答案正确率”。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这很像 Java 里的 &lt;code&gt;Comparator&lt;/code&gt;：它能给对象排出先后，却不会替你判断“排第一的就是正确答案”。向量分数也一样——只负责排队，不负责下结论。&lt;/p&gt;
&lt;p&gt;密集检索的通行做法，就是分别编码问题和文本片段，再用相似度挑选候选上下文，DPR 论文用的正是这种双编码器思路。&lt;a class="link" href="https://aclanthology.org/2020.emnlp-main.550/" target="_blank" rel="noopener"
 &gt;Karpukhin 等，2020&lt;/a&gt; 我们选余弦相似度，只是为了把计算摊开看，不代表所有检索系统都这么算。&lt;/p&gt;
&lt;h2 id="相似度怎么算"&gt;相似度怎么算
&lt;/h2&gt;&lt;p&gt;候选片段和查询都变成了向量，“近不近”就变成了数值比较。那这个数值具体怎么算？这一节用余弦相似度回答。&lt;/p&gt;
&lt;p&gt;$$
\operatorname{cosine}(q,d)=\frac{q \cdot d}{\lVert q \rVert\lVert d \rVert}
$$&lt;/p&gt;
&lt;p&gt;&lt;code&gt;q&lt;/code&gt; 是查询向量，&lt;code&gt;d&lt;/code&gt; 是一条候选片段向量。分子把两个向量逐维相乘再相加（点积），衡量它们在每个维度上是不是朝同一个方向使劲；分母乘上两个向量的长度，避免“向量长分数就高”。结果有直观的含义：同方向的向量分数接近 1，垂直的接近 0，反方向接近 -1。排序器关心的，就是谁离 1 更近。&lt;/p&gt;
&lt;p&gt;余弦公式有两个必须预先处理的边界：两条向量要同维、非零。维度不一致，逐维相乘根本对不上；零向量没有方向，分母一除就崩。排序器在这里主动报错而不是硬算，是为了让配置错误早点暴露，而不是让一个坏分数混进排序。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;计算两个同维、非零向量的余弦相似度。&amp;#34;&amp;#34;&amp;#34;&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;向量维度不一致，不能计算余弦相似度&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# 点积衡量两个向量在各维度上的同向程度。&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dot_product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;left_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;right_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# 零向量没有方向；继续计算会产生除零错误，也没有检索意义。&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="n"&gt;left_norm&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;right_norm&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;零向量没有方向，不能计算余弦相似度&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dot_product&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left_norm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;right_norm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这一段代码和公式一一对应：先检查输入，再算点积，再算两个长度，最后相除。拒绝分支不是额外负担，它就是“余弦在不同输入上的定义边界”。&lt;/p&gt;
&lt;h2 id="只取前-k-名"&gt;只取前 K 名
&lt;/h2&gt;&lt;p&gt;三个候选都有分数了，接下来怎么挑？答案朴素：按分数从高到低排，取前 K 个。这就是 Top-K 里的“K”。&lt;/p&gt;
&lt;p&gt;排序里有一个容易忽略的小决策：分数并列时怎么办。&lt;code&gt;rank_candidates&lt;/code&gt; 用原始下标做第二排序键，保证同一份输入反复运行得到同一份顺序——方便测试和复现，但不代表两个并列候选在业务上有高低。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rank_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;candidate_vectors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RankedCandidate&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;按余弦相似度降序返回前 ``top_k`` 个候选。&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;candidate_vectors&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;候选片段数量与候选向量数量必须一致&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="ne"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;top_k 必须大于 0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ranked_with_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&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="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RankedCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate_vectors&lt;/span&gt;&lt;span class="p"&gt;))&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ranked_with_index&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_with_index&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;RankedCandidate&lt;/code&gt; 只是把候选片段和分数绑在一起的容器。真正的逻辑只有三步：逐条算分数、按分数降序排（分数打平看原始下标）、切片取前 &lt;code&gt;top_k&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;用一个玩具例子看：查询向量 &lt;code&gt;[1.0, 0.0]&lt;/code&gt;，三个候选向量是 &lt;code&gt;[1.0, 0.0]&lt;/code&gt;、&lt;code&gt;[2.0, 0.0]&lt;/code&gt;、&lt;code&gt;[0.0, 1.0]&lt;/code&gt;，分数分别是 1.0、1.0、0.0。取 &lt;code&gt;top_k=2&lt;/code&gt;，结果是前两个候选——同分时保住原始顺序，第三个出局。&lt;/p&gt;
&lt;h2 id="先把本地验证跑起来"&gt;先把本地验证跑起来
&lt;/h2&gt;&lt;p&gt;排序逻辑不依赖云端，验证也先离线做。运行：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;&lt;span class="nb"&gt;cd&lt;/span&gt; /Users/renxin/ai_brain/GYR
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3.11 -m unittest discover -s c02-embedding-topk/tests -v
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;测试喂的全是固定小向量，把排序器的不变量直接写进断言：同方向向量 &lt;code&gt;[3.0, 4.0]&lt;/code&gt; 和 &lt;code&gt;[6.0, 8.0]&lt;/code&gt; 的余弦必须是 1.0；并列分数的三个候选取前两名必须稳定得到 &lt;code&gt;first&lt;/code&gt;、&lt;code&gt;second&lt;/code&gt;。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertAlmostEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rank_candidates&lt;/span&gt;&lt;span class="p"&gt;(&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="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&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="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;first&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;second&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这次实际运行 4/4 通过：同向向量、零向量拒绝、维度不一致拒绝、并列分数稳定排序。&lt;/p&gt;
&lt;p&gt;为什么坚持先离线验证？因为云端调用不可重放——每次可能在小数后几位漂移，还要消耗配额；而排序逻辑是确定性的，它该在本地一次测对。云端只负责一件事：把文本变成向量。&lt;/p&gt;
&lt;h2 id="接上真实模型"&gt;接上真实模型
&lt;/h2&gt;&lt;p&gt;本地排序器验证完毕，现在把玩具向量换成百炼 Embedding 的真实输出。接云端要做三件事：设环境变量、跑 &lt;code&gt;main.py&lt;/code&gt;、看输出。&lt;/p&gt;
&lt;p&gt;请求走的是 OpenAI 兼容接口的 &lt;code&gt;/embeddings&lt;/code&gt;：把模型名和输入文本发过去，拿回一串浮点数。Base URL 和 API Key 从环境变量读取，不要写进代码、文章或 Git。&lt;/p&gt;
&lt;div class="highlight"&gt;&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;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;DASHSCOPE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;你的密钥&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;DASHSCOPE_COMPATIBLE_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;https://你的业务空间域名/compatible-mode/v1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3.11 c02-embedding-topk/main.py --top-k &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这次真实运行（模型 &lt;code&gt;qwen3.7-text-embedding&lt;/code&gt;，请求 1024 维、实际返回 1024 维）的输出是这个形状，以第一个问题为例（候选正文为排版截断，分数与排序未改）：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;model=qwen3.7-text-embedding
requested_dimension=1024
actual_dimension=1024

query=这双鞋七天内能退吗？
1. return-policy	score=0.650210	NovaTrail X2 签收后 7 个自然日…
2. shipping-policy	score=0.445476	NovaTrail X2 标准配送通常在付款后…
3. size-guide	score=0.418887	NovaTrail X2 提供黑色 M 码与 L 码…
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;模型、地域和业务空间配置都可能变化，接入时以你的控制台和&lt;a class="link" href="https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api/" target="_blank" rel="noopener"
 &gt;官方接口说明&lt;/a&gt;为准。&lt;/p&gt;
&lt;p&gt;这里有一个容易忽略的边界。&lt;code&gt;main.py&lt;/code&gt; 把“候选片段”和“用户问题”分成两批请求，代码里保留了 &lt;code&gt;document&lt;/code&gt; 与 &lt;code&gt;query&lt;/code&gt; 两种角色，这是为了对齐百炼“检索任务建议区分查询和文档”的惯例；但我们这次用的是 OpenAI 兼容模式，接口里没有对应的角色字段——本地变量叫 &lt;code&gt;query&lt;/code&gt;，不代表服务端真的按“查询模式”去编码。&lt;/p&gt;
&lt;h2 id="跑出来的结果直觉对了边界也露出来了"&gt;跑出来的结果：直觉对了，边界也露出来了
&lt;/h2&gt;&lt;p&gt;三个问题都跑完，Top-1 汇总如下：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;用户提问&lt;/th&gt;
					&lt;th&gt;Top-1 候选&lt;/th&gt;
					&lt;th style="text-align: right"&gt;分数&lt;/th&gt;
					&lt;th&gt;能说明什么&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;这双鞋七天内能退吗？&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;return-policy&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.650210&lt;/td&gt;
					&lt;td&gt;这次口语化退货问题把退货片段排在第一。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;下单后多久能发货？&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;shipping-policy&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.614013&lt;/td&gt;
					&lt;td&gt;换成物流问题后，同一候选集的第一名随之变化。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;NS-X2-BLK-M 什么时候发货？&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;shipping-policy&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.686929&lt;/td&gt;
					&lt;td&gt;“发货”语义占了主导；不能据此证明 SKU 精确检索可靠。&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;顺带说明：同一模型、同一候选集，分数会在第四位小数附近轻微漂移，这正是“分数不能当阈值”的一部分。以上面这次运行为准。&lt;/p&gt;
&lt;p&gt;第三行最容易让人误判。“NS-X2-BLK-M 什么时候发货”确实把“发货政策”排到了第一，看着像答对了方向；可候选资料里根本没有这件 SKU 的发货记录。这个结果只能说明“发货”两个字在语义上占了上风，证明不了 SKU 检索可用。&lt;/p&gt;
&lt;h2 id="分数能做什么不能做什么"&gt;分数能做什么、不能做什么
&lt;/h2&gt;&lt;p&gt;开篇承诺的三件事，到这里都能回答了：问题是怎样变成数字的（Embedding），三条政策是怎样排出先后的（余弦相似度 + Top-K），排第一意味着什么——它意味着“在当前候选集合里最靠前”，仅此而已。&lt;/p&gt;
&lt;p&gt;Top-K 的作用很朴素：把后续要看的资料缩小到几段。至于分数本身，别指望它替你判断下面这些事：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;不能直接据此判断&lt;/th&gt;
					&lt;th&gt;原因&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;最终答案正确&lt;/td&gt;
					&lt;td&gt;回答仍可能误读、遗漏或编造候选内容。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;证据足够&lt;/td&gt;
					&lt;td&gt;高分只表示相对靠前，不代表覆盖了回答所需条件。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;可以忽略精确词&lt;/td&gt;
					&lt;td&gt;SKU、订单号、政策版本等字段常需要过滤或关键词能力补充。&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;一个分数阈值通吃&lt;/td&gt;
					&lt;td&gt;分数依赖模型、候选集合、切分方式和相似度定义。&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一句话收束：&lt;strong&gt;排第一不等于答案可信。&lt;/strong&gt; 0.65 只说明“退货片段排在最前”，不说明“这条退货规则就是答案”。所以 Agent 的检索工具到这里就该停下——它交回候选证据和来源，而不是把分数包装成“答案可信”。之后的回答、过滤、引用、拒答，仍然是编排层要承担的事。&lt;/p&gt;
&lt;h2 id="结尾一整篇政策只有一个分数"&gt;结尾：一整篇政策，只有一个分数
&lt;/h2&gt;&lt;p&gt;前面用到的候选都是精心切成一段的小文本。如果候选本身就是一整篇长政策呢？我们把退货政策扩成六个条款：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;NovaTrail X2 退货政策：签收后 7 个自然日内可申请退货；商品需保持未使用状态、包装完整，并保留全部配件与购买凭证；因商品质量问题退货时，运费由 NovaShop 承担，退款将在收货后 3 个工作日内原路退回；因个人原因（尺码、喜好等）退货时，运费由买家承担；特价清仓商品与定制商品不支持无理由退货；运输途中损坏的商品可以申请换货。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;问它：“鞋收到是坏的，退货运费谁出？”（环境变量沿用上一节，运行 &lt;code&gt;main_long_policy.py&lt;/code&gt;）&lt;/p&gt;
&lt;p&gt;这次的真实结果：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;-- 整篇政策作为一个候选（整篇只有一个分数）--
1. return-policy-full	score=0.585282	NovaTrail X2 退货政策：…

-- 同一篇政策拆成条款逐条打分（预告第三话的切分视角）--
1. clause-4	score=0.708807	因个人原因（尺码、喜好等）退货时，运费由买家承担；
2. clause-3	score=0.697954	因商品质量问题退货时，运费由 NovaShop 承担，退款将在收货后 3 个工作日内原路退回；
3. clause-6	score=0.675624	运输途中损坏的商品可以申请换货。
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;整篇政策只有一个分数 0.585282——我们只知道“这篇政策相关”，却不知道是哪个条款把它顶上去的。拆成条款后，三条与“运费/损坏”相关的条款全部浮到前面，定位明显变细。&lt;/p&gt;
&lt;p&gt;这里还有一层诚实的噪声：排第一的是条款 4（“个人原因，买家承担”），而不是条款 3（“质量问题，NovaShop 承担”）。“坏的”按语义应该指向质量问题，但“运费谁出”的表层表达把条款 4 顶了上来。也就是说：分块让“哪一段相关”变得可见，但并没有让排序变成正确——检索找到候选是一回事，候选能不能支撑回答是另一回事，那要等后面的章节处理。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二话到这里，检索单元的粒度问题就摆上台面了：一整篇政策只算一个候选片段，粒度还是太粗。长文档到底该切成多大一块，才能既找得到规则，又不丢掉规则的上下文？&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;a class="link" href="https://help.aliyun.com/zh/model-studio/embedding" target="_blank" rel="noopener"
 &gt;阿里云百炼：向量化（Embedding）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api/" target="_blank" rel="noopener"
 &gt;阿里云百炼：通用文本向量同步接口 API 详情&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://aclanthology.org/2020.emnlp-main.550/" target="_blank" rel="noopener"
 &gt;Dense Passage Retrieval for Open-Domain Question Answering&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>第十六话｜SSE 是什么？为什么它会用在 AI Agent 开发中</title><link>https://zh.renxinblog.cn/post/sse-in-ai-agent-development/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/sse-in-ai-agent-development/</guid><description>&lt;p&gt;前面把 Agent 的循环、状态、记忆和工具接起来之后，用户仍可能面对一个黑盒。&lt;/p&gt;
&lt;p&gt;他发出“帮我检查这个仓库的测试为什么失败”，页面却在几十秒内没有任何变化。Agent 也许正在生成，也许正在调用工具，也许已经失败；用户无从分辨。&lt;/p&gt;
&lt;p&gt;问题出在传输方式上：普通 HTTP 是一问一答——客户端发一个请求，服务端算完最后一次性返回完整响应。Agent 干活要几十秒，响应就空白几十秒。要让 Agent 不再像黑盒，前端不只需要最终答案，还要持续接收文本增量、工具状态和 Run 的终态。&lt;/p&gt;
&lt;p&gt;SSE（Server-Sent Events）就是把这类&lt;strong&gt;服务端到浏览器的连续事件&lt;/strong&gt;放进 HTTP 的一种标准方式。结论先说：&lt;strong&gt;SSE 不负责让 Agent 变聪明；它负责把 Agent 已经发生的事，按事件边界可靠地呈现给 UI。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="一旧方案为什么看不见"&gt;一、旧方案为什么看不见
&lt;/h2&gt;&lt;p&gt;“给我一个答案”用普通 HTTP 正合适：提交命令，取回结果，一次交互结束。但“让我看着你干活”不行——服务端在没有完整结果前不会返回响应，浏览器也就拿不到任何进度。&lt;/p&gt;
&lt;p&gt;要让用户看见进度，服务端必须&lt;strong&gt;在计算完成前就开始输出&lt;/strong&gt;，并且持续输出。这正是 SSE 站在 HTTP 上的理由：一个响应保持打开，服务端不断写入“有边界的小段文本”。&lt;/p&gt;
&lt;p&gt;这里有个经常被混在一起的概念要拆开说清楚。&lt;strong&gt;HTTP 持久连接&lt;/strong&gt;（persistent connection / Keep-Alive）指的是底层连接可以复用、承载多次请求响应，它降低建连成本，但服务端不会在没有新请求时主动推送业务消息。&lt;a class="link" href="https://www.rfc-editor.org/rfc/rfc9112.html#section-9.3" target="_blank" rel="noopener"
 &gt;RFC 9112 §9.3&lt;/a&gt; &lt;strong&gt;长轮询&lt;/strong&gt;是应用层写法：服务端先不立即返回，等到有数据或超时才结束响应，浏览器收到后再发起下一次请求。它们都不等于“一个持续打开、服务端主动下推的响应”。&lt;/p&gt;
&lt;p&gt;四种常用方式的层次差别，一张表就能看明白：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方式&lt;/th&gt;
					&lt;th&gt;它解决什么&lt;/th&gt;
					&lt;th&gt;通信方向&lt;/th&gt;
					&lt;th&gt;一次交互是否持续&lt;/th&gt;
					&lt;th&gt;Agent 中合适的职责&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;普通 HTTP&lt;/td&gt;
					&lt;td&gt;提交命令、取得一次结果&lt;/td&gt;
					&lt;td&gt;双向，但一问一答&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
					&lt;td&gt;创建 Run、提交用户输入、取消任务、确认敏感工具调用&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;HTTP 持久连接&lt;/td&gt;
					&lt;td&gt;复用底层连接，减少建连成本&lt;/td&gt;
					&lt;td&gt;仍是多次 HTTP 请求/响应&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
					&lt;td&gt;通常由客户端、网关和连接池处理，不是事件通道方案&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;SSE&lt;/td&gt;
					&lt;td&gt;服务端持续发送有边界的文本事件&lt;/td&gt;
					&lt;td&gt;服务端 → 浏览器&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
					&lt;td&gt;文本增量、工具进度、Run 状态、最终结果&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;WebSocket&lt;/td&gt;
					&lt;td&gt;持续的双向消息&lt;/td&gt;
					&lt;td&gt;双向&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
					&lt;td&gt;协同编辑、高频客户端指令、双方都需随时发消息的交互&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;figure class="gallery-image"&gt;
 &lt;a class="image-link" href="https://zh.renxinblog.cn/images/sse-in-ai-agent-development-http-protocol-relationships.svg" data-pswp-width="746" data-pswp-height="622" target="_blank"&gt;
 &lt;img src="https://zh.renxinblog.cn/images/sse-in-ai-agent-development-http-protocol-relationships.svg" width="746" height="622" alt="四种通信方式处在不同层次：HTTP 持久连接复用连接，SSE 持续响应，WebSocket 在 Upgrade 后使用双向帧"&gt;
 &lt;/a&gt;
&lt;/figure&gt;
&lt;p&gt;WebSocket 与 HTTP 的关系也要说准确：它用 HTTP Upgrade 完成开场握手，握手成功后双方交换的是 WebSocket 帧，而不是继续交换 HTTP 响应体。&lt;a class="link" href="https://datatracker.ietf.org/doc/html/rfc6455#section-4" target="_blank" rel="noopener"
 &gt;RFC 6455 §4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键洞察：持久连接是连接复用策略；SSE 是 HTTP 上的事件流格式；WebSocket 是经 HTTP 握手切换出的双向协议。它们不在同一抽象层。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="二亲眼看sse-在线上传了什么"&gt;二、亲眼看：SSE 在线上传了什么
&lt;/h2&gt;&lt;p&gt;浏览器用 &lt;code&gt;EventSource&lt;/code&gt; 发起请求，服务端以 &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt; 返回响应，并持续写入按行组织的 UTF-8 文本。每个事件以空行结束。&lt;a class="link" href="https://html.spec.whatwg.org/multipage/server-sent-events.html" target="_blank" rel="noopener"
 &gt;WHATWG HTML Standard §9.2&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;下面是一个完整事件，不是一段“随便输出的文本”：&lt;/p&gt;
&lt;div class="highlight"&gt;&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
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: text.delta
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_123&amp;#34;,&amp;#34;delta&amp;#34;:&amp;#34;正在读取测试报告…&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;四个常用字段的职责如下。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;字段&lt;/th&gt;
					&lt;th&gt;协议层含义&lt;/th&gt;
					&lt;th&gt;在 Agent 流中的用法&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;data&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;事件数据；可以多行，浏览器会按规范合并&lt;/td&gt;
					&lt;td&gt;放 JSON payload，例如文本增量或工具结果摘要&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;event&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;事件类型；省略时是默认 &lt;code&gt;message&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;区分 &lt;code&gt;text.delta&lt;/code&gt;、&lt;code&gt;tool.started&lt;/code&gt;、&lt;code&gt;run.completed&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;更新浏览器保存的最后事件 ID&lt;/td&gt;
					&lt;td&gt;用作断线续传的游标；服务端仍需校验 Run 归属&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;retry&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;建议浏览器下一次重连前等待的毫秒数&lt;/td&gt;
					&lt;td&gt;仅调整重连等待，不解决事件去重或业务恢复&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;注意最后一行的空行不是装饰。它是事件边界：如果连接在空行前结束，尚未完成的事件不会被分发。&lt;a class="link" href="https://html.spec.whatwg.org/multipage/server-sent-events.html" target="_blank" rel="noopener"
 &gt;WHATWG HTML Standard §9.2.5–9.2.6&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;浏览器端不需要自行解析每一行：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`/runs/&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;/events`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text.delta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendAssistantText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;);&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;run.completed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;EventSource&lt;/code&gt; 对断开连接具备重连处理，并在已有事件 ID 时把 &lt;code&gt;Last-Event-ID&lt;/code&gt; 带回服务端。它提供的是恢复的协议钩子，&lt;strong&gt;不是“消息恰好一次送达”的保证&lt;/strong&gt;；应用仍要定义续传、去重和终态行为。&lt;a class="link" href="https://html.spec.whatwg.org/multipage/server-sent-events.html" target="_blank" rel="noopener"
 &gt;WHATWG HTML Standard §9.2.3–9.2.4&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="三事件的形状由业务决定agent-run-事件契约"&gt;三、事件的形状由业务决定：Agent Run 事件契约
&lt;/h2&gt;&lt;p&gt;只把模型 token 一段段传到 UI，用户会看到“它在说话”，但仍不知道它是否在行动。&lt;/p&gt;
&lt;p&gt;对一个会调用工具的 Agent，更有用的做法是把 Run 生命周期投影成一组应用层事件：&lt;/p&gt;
&lt;figure class="gallery-image"&gt;
 &lt;a class="image-link" href="https://zh.renxinblog.cn/images/sse-in-ai-agent-development-agent-run-event-flow.svg" data-pswp-width="864" data-pswp-height="288" target="_blank"&gt;
 &lt;img src="https://zh.renxinblog.cn/images/sse-in-ai-agent-development-agent-run-event-flow.svg" width="864" height="288" alt="一次 Agent Run 中，普通 HTTP 用来创建任务，SSE 用来把模型与工具产生的下行事件送到浏览器"&gt;
 &lt;/a&gt;
&lt;/figure&gt;
&lt;p&gt;例如，“检查测试为什么失败”这个 Run 在客户端看来可以是这样一串事件：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;event: text.delta
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_123&amp;#34;,&amp;#34;delta&amp;#34;:&amp;#34;我先运行测试。&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: tool.started
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_123&amp;#34;,&amp;#34;tool&amp;#34;:&amp;#34;run_tests&amp;#34;,&amp;#34;callId&amp;#34;:&amp;#34;call_7&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: tool.completed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_123&amp;#34;,&amp;#34;callId&amp;#34;:&amp;#34;call_7&amp;#34;,&amp;#34;summary&amp;#34;:&amp;#34;3 个测试失败&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: run.completed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_123&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这些名称不是 SSE 规定的字段值，而是本文建议的&lt;strong&gt;业务事件契约&lt;/strong&gt;。SSE 只规定如何分帧、如何指定事件类型、如何传递最后事件 ID；&lt;code&gt;tool.started&lt;/code&gt; 是否存在、数据结构是什么、是否向用户展示工具结果，都由你的 Agent 产品和安全策略决定。&lt;/p&gt;
&lt;p&gt;这和 Java 微服务中的“领域事件”和“Kafka 的传输记录”很像：Kafka 不替你定义订单状态机；SSE 也不替你定义 Agent Run 状态机。先定义业务状态和权限边界，再选择传输通道。&lt;/p&gt;
&lt;h2 id="四动手跑通一条本地-agent-run-事件流"&gt;四、动手：跑通一条本地 Agent Run 事件流
&lt;/h2&gt;&lt;p&gt;这一节的 demo 不调用真实模型，避免让 API Key、模型费用或网络问题遮住协议本身。服务端固定模拟一个 Run：它先发送文本增量，再报告工具开始、工具完成，最后发送 Run 完成事件。&lt;/p&gt;
&lt;p&gt;配套代码有两份等价实现：Python 位于 GYA 的 &lt;code&gt;c16-sse-agent-stream/&lt;/code&gt;，Java 位于 GYA-Java 的同名 Gradle 子模块。正文以 Python 为主，Java 版用于验证同一协议不依赖语言或 Spring 框架。&lt;/p&gt;
&lt;h3 id="python标准库即可启动"&gt;Python：标准库即可启动
&lt;/h3&gt;&lt;p&gt;前置环境：Python 3.9+ 与 &lt;code&gt;curl&lt;/code&gt;，无第三方依赖、无 API Key。&lt;/p&gt;
&lt;p&gt;终端一启动服务：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;git clone git@github.com:renxin2024/GYA.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; GYA/c16-sse-agent-stream
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 main.py --once
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;终端二订阅：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;curl -N http://127.0.0.1:8765/events
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;我在本机实际收到的输出如下：&lt;/p&gt;
&lt;div class="highlight"&gt;&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: 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: text.delta
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_demo&amp;#34;,&amp;#34;delta&amp;#34;:&amp;#34;我先运行测试。&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;id: 2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: tool.started
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_demo&amp;#34;,&amp;#34;tool&amp;#34;:&amp;#34;run_tests&amp;#34;,&amp;#34;callId&amp;#34;:&amp;#34;call_1&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;id: 3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: tool.completed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_demo&amp;#34;,&amp;#34;callId&amp;#34;:&amp;#34;call_1&amp;#34;,&amp;#34;summary&amp;#34;:&amp;#34;3 个测试失败&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;id: 4
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event: run.completed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;data: {&amp;#34;runId&amp;#34;:&amp;#34;run_demo&amp;#34;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里最重要的不是 &lt;code&gt;sleep(0.1)&lt;/code&gt;，而是每次写完一帧后立刻 flush，并且用最后那个空行完成事件。把空行删掉，再用浏览器 &lt;code&gt;EventSource&lt;/code&gt; 接收时，读者会发现它不会把这一帧当作完成的事件。&lt;/p&gt;
&lt;h3 id="java同一事件流不换协议"&gt;Java：同一事件流，不换协议
&lt;/h3&gt;&lt;p&gt;前置环境：JDK 21 与 &lt;code&gt;curl&lt;/code&gt;。GYA-Java 使用仓库自带的 Gradle Wrapper；首次运行会按仓库配置下载 Gradle 和 Jackson。&lt;/p&gt;
&lt;p&gt;终端一启动：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;git clone git@github.com:renxin2024/GYA-Java.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; GYA-Java
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./gradlew :c16-sse-agent-stream:run --args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--once&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;终端二订阅：&lt;/p&gt;
&lt;div class="highlight"&gt;&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;curl -N http://127.0.0.1:8766/events
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Java 版构建通过，并按相同顺序输出 &lt;code&gt;text.delta&lt;/code&gt;、&lt;code&gt;tool.started&lt;/code&gt;、&lt;code&gt;tool.completed&lt;/code&gt;、&lt;code&gt;run.completed&lt;/code&gt;。核心的分帧代码和 Python 同构，只是把字符串拼接换成了 Jackson 序列化 payload：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;throws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;IOException&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&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;// SSE 是 UTF-8 的按行协议。最后的空行是事件边界；删除它会让&lt;/span&gt;&lt;span class="w"&gt;
&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;// EventSource 持续等待，而不是把前面的字段作为一条完整事件分发出去。&lt;/span&gt;&lt;span class="w"&gt;
&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="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;frame&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="s"&gt;&amp;#34;id: &amp;#34;&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="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;()&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="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&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="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;event: &amp;#34;&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="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;()&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="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&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="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;data: &amp;#34;&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="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeValueAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;())&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="s"&gt;&amp;#34;\n\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&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="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StandardCharsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&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;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Event&lt;/code&gt; 是一个只保存 &lt;code&gt;id&lt;/code&gt;、&lt;code&gt;type&lt;/code&gt;、&lt;code&gt;data&lt;/code&gt; 的 record；&lt;code&gt;EVENTS&lt;/code&gt; 里和 Python 一样放着四个固定事件。既然协议的“形状”完全由分帧代码决定，换语言、换框架都不会改变这条事件流的语义——这正是把协议层与业务层分开的价值。&lt;/p&gt;
&lt;p&gt;如果 Java 版本因 toolchain 无法启动，先运行 &lt;code&gt;java -version&lt;/code&gt; 确认实际环境是 JDK 21。这个 demo 故意保持 Java 21，不为了迁就本机默认 JDK 而降低系列约束。另外，不要把 Python 与 Java 输出的 JSON 键顺序当作正确性标准：JSON 字段顺序不属于 SSE 契约。&lt;/p&gt;
&lt;h2 id="五能连通--体验到好三个坑"&gt;五、能连通 ≠ 体验到好：三个坑
&lt;/h2&gt;&lt;p&gt;连得上、有输出，不代表体验是对的。下面是三个最常见的“能连通、但体验很差”的问题。&lt;/p&gt;
&lt;h3 id="1-服务端在发用户却最后一次性看到"&gt;1. 服务端在发，用户却最后一次性看到
&lt;/h3&gt;&lt;p&gt;SSE 需要端到端按事件及时传输。规范明确提醒：不恰当的块缓冲可能延迟事件分发。&lt;a class="link" href="https://html.spec.whatwg.org/multipage/server-sent-events.html" target="_blank" rel="noopener"
 &gt;WHATWG HTML Standard §9.2.5&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;因此排查顺序不该从“模型为什么这么慢”开始，而应沿着浏览器 → CDN/网关 → 反向代理 → 应用服务器逐跳确认：响应类型是否正确、是否有缓冲、应用是否真的在事件产生时写出。&lt;code&gt;curl&lt;/code&gt; 先关掉客户端缓冲（&lt;code&gt;curl -N&lt;/code&gt;）；如果 curl 能一帧帧出来、浏览器不行，问题大概率在中间层。具体关闭何种缓冲取决于网关产品和部署方式，不能从一段通用配置照抄。&lt;/p&gt;
&lt;h3 id="2-断线后重复显示或者漏掉关键状态"&gt;2. 断线后重复显示，或者漏掉关键状态
&lt;/h3&gt;&lt;p&gt;浏览器会携带 &lt;code&gt;Last-Event-ID&lt;/code&gt; 重连，但服务端必须决定这个 ID 的语义：它是某个 Run 内单调递增的序号，还是全局事件 ID？事件保留多久？补发窗口过期时如何回退？&lt;/p&gt;
&lt;p&gt;一个可维护的最小约定是：&lt;code&gt;runId + eventId&lt;/code&gt; 唯一；客户端按该组合去重；服务端只允许已授权的主体按 &lt;code&gt;runId&lt;/code&gt; 查询或续传。这里的授权检查不能因为客户端带了一个事件 ID 就被跳过。&lt;/p&gt;
&lt;h3 id="3-run-已结束连接和资源还在等待"&gt;3. Run 已结束，连接和资源还在等待
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;run.completed&lt;/code&gt;、&lt;code&gt;run.failed&lt;/code&gt;、&lt;code&gt;run.cancelled&lt;/code&gt; 这类终态应有清晰语义：服务端发送终态事件后结束流，客户端收到后关闭 &lt;code&gt;EventSource&lt;/code&gt; 并更新界面。心跳注释可以帮助发现中间链路的空闲超时，但心跳不应伪装成业务进度。&lt;a class="link" href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" target="_blank" rel="noopener"
 &gt;MDN SSE 指南&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键洞察：SSE 解决“事件怎样流到浏览器”；事件是否可恢复、可授权、可解释，仍是 Agent Run 模型的责任。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="六回到开场黑盒变成了可见的事件流"&gt;六、回到开场：黑盒变成了可见的事件流
&lt;/h2&gt;&lt;p&gt;现在回头看开头的那个 Run。“帮我检查这个仓库的测试为什么失败”在 demo 里变成了一条可见的时间线：先是“我先运行测试。”的文本增量，然后是工具开始、工具完成（&lt;code&gt;3 个测试失败&lt;/code&gt;），最后 Run 完成。用户不再对着空白页面猜 Agent 是不是卡死了——他能看到 Agent 正在做什么、做到哪了、结果如何。&lt;/p&gt;
&lt;p&gt;这就是这一话真正改变的东西：&lt;strong&gt;“它在动”从结论变成了过程。&lt;/strong&gt; SSE 不负责让 Agent 变聪明，它负责把 Agent 正在做和已经做的事，按事件边界可靠地送到浏览器；之后要不要展示工具结果、要不要拒绝敏感调用，仍然是产品与安全决策。&lt;/p&gt;
&lt;h2 id="七选型什么时候选-sse什么时候不选"&gt;七、选型：什么时候选 SSE，什么时候不选
&lt;/h2&gt;&lt;p&gt;如果 UI 的主要需求是“服务端持续告诉用户发生了什么”，SSE 是很自然的第一候选：浏览器有原生 &lt;code&gt;EventSource&lt;/code&gt;，协议有事件类型和重连语义，接入普通 HTTP 基础设施的成本通常较低。&lt;/p&gt;
&lt;p&gt;如果客户端也需要高频、低延迟地主动发消息，例如多人协同编辑、实时控制面板或双向交互游戏，再评估 WebSocket。若只是提交用户问题、取消 Run 或同意执行高风险工具，普通 HTTP 往往更直观，也更容易把请求、权限校验和审计记录放在清晰的命令边界上。&lt;/p&gt;
&lt;p&gt;不要用“WebSocket 更实时”替代选型分析。对 Agent 来说，最关键的问题是：消息主要朝哪个方向流？是否需要浏览器原生重连与事件 ID？你的 Run 是否有可恢复的状态模型？&lt;/p&gt;
&lt;h2 id="八下一步"&gt;八、下一步
&lt;/h2&gt;&lt;p&gt;这一话放在 GYA 的生产级补充位置：它不增加 Agent 的推理能力，却决定用户能否看见、理解和恢复一次正在运行的 Agent 任务。&lt;/p&gt;
&lt;p&gt;从开场那个 Run 继续往后走，下一步应把同样的事件契约接到真实的 Run 状态存储、鉴权与可观测性中——而不是继续往 SSE 帧里塞业务判断。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;&lt;a class="link" href="https://html.spec.whatwg.org/multipage/server-sent-events.html" target="_blank" rel="noopener"
 &gt;WHATWG HTML Standard — Server-sent events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rfc-editor.org/rfc/rfc9112.html#section-9.3" target="_blank" rel="noopener"
 &gt;RFC 9112 — HTTP/1.1 Persistence&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://datatracker.ietf.org/doc/html/rfc6455" target="_blank" rel="noopener"
 &gt;RFC 6455 — The WebSocket Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events" target="_blank" rel="noopener"
 &gt;MDN — Using server-sent events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html#webmvc-ann-async-sse" target="_blank" rel="noopener"
 &gt;Spring Framework — MVC SSE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://docs.spring.io/spring-framework/reference/web/webflux/controller/ann-methods/return-types.html" target="_blank" rel="noopener"
 &gt;Spring Framework — WebFlux return values&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description></item></channel></rss>