<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Backend on niuzj</title>
    <link>https://niuzj.org/tags/backend/</link>
    <description>Recent content in Backend on niuzj</description>
    <generator>Hugo</generator>
    <language>zh-cn</language>
    <lastBuildDate>Tue, 12 May 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://niuzj.org/tags/backend/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Agent SSE 可恢复流：后端别把长连接当任务生命周期</title>
      <link>https://niuzj.org/posts/agent-sse-resumable-stream/</link>
      <pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate>
      <guid>https://niuzj.org/posts/agent-sse-resumable-stream/</guid>
      <description>&lt;p&gt;做 Agent 聊天时，最开始很容易把 SSE 当成一条“任务管道”。&lt;/p&gt;&#xA;&lt;p&gt;用户点发送，后端开一个流，Agent 一边跑一边往这个 HTTP response 里写数据。浏览器断开，就认为用户不需要了，于是顺手把后台任务也取消。&lt;/p&gt;&#xA;&lt;p&gt;这套在 demo 里很顺。但一到长任务就会暴露问题。&lt;/p&gt;&#xA;&lt;p&gt;生成视频、跑工作流、调图像模型，可能要几十秒甚至几分钟。用户网络抖一下、页面切后台、代理断一下，SSE 连接断了。但 Agent 任务本身不应该死。&lt;/p&gt;&#xA;&lt;p&gt;真正要拆开的，是两件事：&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Agent run&lt;/strong&gt;：后台任务生命周期&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;SSE connection&lt;/strong&gt;：前端订阅事件的传输连接&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;连接可以断，可以重连。run 不能因为连接断了就被杀。&lt;/p&gt;&#xA;&lt;h2 id=&#34;架构图&#34;&gt;架构图&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR&#xA;  FE[&amp;#34;Frontend&amp;lt;br/&amp;gt;sendMessage / resumeActiveRun&amp;lt;br/&amp;gt;POST /v1/agent/runs&amp;lt;br/&amp;gt;fetch-event-source 订阅 events&amp;lt;br/&amp;gt;最近 500 个 SSE id 去重&amp;#34;]&#xA;  API[&amp;#34;Agent API&amp;lt;br/&amp;gt;POST /runs 创建 run&amp;lt;br/&amp;gt;GET /runs/{run_id}/events 订阅&amp;lt;br/&amp;gt;cancel / HITL&amp;lt;br/&amp;gt;active-run 只用于页面恢复&amp;#34;]&#xA;  Producer[&amp;#34;Background Agent Producer&amp;lt;br/&amp;gt;执行 Agent run&amp;lt;br/&amp;gt;写 Redis Stream 事件&amp;lt;br/&amp;gt;刷新 runner lease&amp;lt;br/&amp;gt;最终历史写 MongoDB&amp;#34;]&#xA;  Redis[&amp;#34;Redis Runtime Layer&amp;lt;br/&amp;gt;Stream per run&amp;lt;br/&amp;gt;id + event + data&amp;lt;br/&amp;gt;active run state&amp;lt;br/&amp;gt;runner lease TTL&amp;#34;]&#xA;  SSE[&amp;#34;SSE Events Endpoint&amp;lt;br/&amp;gt;读取 Last-Event-ID header&amp;lt;br/&amp;gt;XREAD Redis Stream&amp;lt;br/&amp;gt;输出标准 SSE&amp;lt;br/&amp;gt;heartbeat comment&amp;#34;]&#xA;  Mongo[&amp;#34;MongoDB Final History&amp;lt;br/&amp;gt;完整聊天历史&amp;lt;br/&amp;gt;最终一致性持久化&amp;#34;]&#xA;&#xA;  FE --&amp;gt;|&amp;#34;create run / 409 active_run_id&amp;#34;| API&#xA;  API --&amp;gt;|&amp;#34;start background task&amp;#34;| Producer&#xA;  Producer --&amp;gt;|&amp;#34;XADD event&amp;#34;| Redis&#xA;  SSE --&amp;gt;|&amp;#34;XREAD from cursor&amp;#34;| Redis&#xA;  FE -.-&amp;gt;|&amp;#34;SSE reconnect with Last-Event-ID&amp;#34;| SSE&#xA;  SSE -.-&amp;gt;|&amp;#34;id / event / data&amp;#34;| FE&#xA;  Producer --&amp;gt;|&amp;#34;final messages&amp;#34;| Mongo&#xA;  SSE -.-&amp;gt;|&amp;#34;closes only on run.completed / run.failed / run.cancelled&amp;#34;| FE&#xA;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;这个图里最重要的箭头不是 SSE，而是 &lt;code&gt;Producer -&amp;gt; Redis&lt;/code&gt;。&lt;/p&gt;</description>
    </item>
    <item>
      <title>支付重构里，我最后只信 Redis 那一扣</title>
      <link>https://niuzj.org/posts/payment-billing-refactor-redis-mongodb/</link>
      <pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate>
      <guid>https://niuzj.org/posts/payment-billing-refactor-redis-mongodb/</guid>
      <description>&lt;p&gt;这次改支付链路，最开始我想错了一个地方。&lt;/p&gt;&#xA;&lt;p&gt;我以为问题是“Agent 也要扣费”，所以把 Consumer 里那套工作流扣费逻辑搬过来就行。&lt;/p&gt;&#xA;&lt;p&gt;后来发现不是。&lt;/p&gt;&#xA;&lt;p&gt;真正的问题是：&lt;strong&gt;一次生成请求，到底在哪一刻算占用了用户余额？&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;这个问题如果不先回答，代码会很快散掉。LLM 在一个地方扣，tool 在另一个地方扣，工作流节点又有一套预扣。每条路都能跑，但每条路都在暗示不同的账务语义。&lt;/p&gt;&#xA;&lt;p&gt;最后我接受的原则很简单：&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Redis 决定请求能不能继续跑。MongoDB 负责把这次消费写成账本。&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;也就是先过运行期闸门，再补最终账本。&lt;/p&gt;&#xA;&lt;h2 id=&#34;旧结构哪里开始疼&#34;&gt;旧结构哪里开始疼&lt;/h2&gt;&#xA;&lt;p&gt;OpenCreator 原来的重扣费场景主要在 Workflow Consumer。&lt;/p&gt;&#xA;&lt;p&gt;图片、视频、音频这些节点，通常能在调用模型前算出费用。Consumer 会在 Model Gateway 里先走 &lt;code&gt;CreditBalanceCheckServiceV2.check_for_pre_deduction()&lt;/code&gt;，Redis 里扣成功后再调用外部模型。&lt;/p&gt;&#xA;&lt;p&gt;这个结构放在工作流里是顺的。&lt;/p&gt;&#xA;&lt;p&gt;节点要调用供应商，供应商可能会产生真实成本。余额不够，就别调用。调用失败，就 rollback。&lt;/p&gt;&#xA;&lt;p&gt;Agent 接进来以后，事情变得没那么整齐。&lt;/p&gt;&#xA;&lt;p&gt;Agent 一轮对话里有多次 LLM 调用。LLM token 费用要等 usage 回来才知道。tool 也可能返回 &lt;code&gt;cost_usd&lt;/code&gt;，比如搜索工具、渲染工具、媒体分析工具。subagent 还可能继续开一条新的执行链。&lt;/p&gt;&#xA;&lt;p&gt;如果沿着旧思路，把扣费写在 API handler 或 SSE hook 里，肯定会漏路径。&lt;/p&gt;&#xA;&lt;p&gt;后来这部分被下沉到了 &lt;code&gt;creato/core/executor.py&lt;/code&gt;：&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;async&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;_check_balance&lt;/span&gt;(self) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ctx &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; get_billing_context()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx &lt;span style=&#34;color:#f92672&#34;&gt;or&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;enabled:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;check_balance_or_raise(ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;user_id)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;async&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;_bill_llm&lt;/span&gt;(self, usage: dict[str, int]) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ctx &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; get_billing_context()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx &lt;span style=&#34;color:#f92672&#34;&gt;or&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;enabled &lt;span style=&#34;color:#f92672&#34;&gt;or&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; usage:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;bill_llm(ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;user_id, self&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;model, usage, ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;session_id)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;async&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;_bill_tool&lt;/span&gt;(self, tool_name: str, cost_usd: float &lt;span style=&#34;color:#f92672&#34;&gt;|&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ctx &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; get_billing_context()&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx &lt;span style=&#34;color:#f92672&#34;&gt;or&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;enabled &lt;span style=&#34;color:#f92672&#34;&gt;or&lt;/span&gt; cost_usd &lt;span style=&#34;color:#f92672&#34;&gt;is&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;manager&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;bill_tool(ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;user_id, tool_name, cost_usd, ctx&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;session_id)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这个改动不是单纯挪位置。&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
