2026/11/4 14:11:26

Electron+FastAPI流式Agent对话架构设计与实战

Electron+FastAPI流式Agent对话架构设计与实战 1. 这不是“前后端联调”而是一场跨进程实时对话的精密协同Electron 与 FastAPI 的组合常被误读为“桌面版 Web 应用 后端 API”的简单叠加。但当你真正要实现流式 Agent 对话——即用户在 Electron 界面中输入问题后端 FastAPI 实时逐字返回大模型思考过程token 级别流式输出前端同步渲染、支持随时 Abort 中断、并维持多轮对话上下文状态——你就立刻掉进了一个典型的“三重边界”陷阱进程边界主进程 vs 渲染进程、协议边界HTTP/HTTPS vs IPC vs WebSocket/SSE、状态边界无状态 HTTP 请求 vs 有状态 Agent 生命周期。我去年用这套架构落地过一个本地知识库问答工具初期踩了整整三周坑前端显示“正在思考…”卡死 8 秒才吐出第一句中断请求根本无效换话题后模型居然还在续写上一轮的结尾。后来发现问题根本不在模型本身而在 Electron 和 FastAPI 之间那层看似透明、实则布满暗礁的通信管道。核心关键词必须前置厘清Electron不是浏览器它是 Chromium Node.js 的双运行时容器FastAPI不是 Flask 的语法糖它的异步流式响应能力StreamingResponse EventSource是流式 Agent 的底层支柱流式指的是 token 级别的增量数据推送而非 chunk 或整句返回Agent意味着它必须携带状态历史消息、工具调用栈、记忆缓存不能当作无状态函数调用对话则要求严格的时间序、可中断性、上下文隔离A 用户的对话绝不能污染 B 用户的 session。这五个词任何一个理解偏差都会导致整个流式体验崩塌。比如很多人以为用fetch调 FastAPI 的/chat接口就能搞定流式结果发现fetch的ReadableStream在 Electron 渲染进程中对AbortController的兼容性极差Chrome 110 才稳定支持而 Electron 22 默认绑的是 Chromium 108 —— 这就是为什么你看到“abort 失效”的热搜词高频出现。真正的解法从来不是升级 Electron而是绕开 fetch用更底层、更可控的通信机制。2. 主进程才是流式对话的“交通指挥中心”渲染进程只负责“看和说”绝大多数 Electron 新手会本能地让 Vue/React 渲染进程直接通过fetch或axios去调 FastAPI 接口。这是最直观的路径也是最危险的起点。原因有三第一渲染进程的网络请求受 Chromium 安全策略限制如 CORS、混合内容拦截尤其当 FastAPI 运行在http://localhost:8000而 Electron 页面是file://协议时CORS 直接报错第二fetch的流式读取在旧版 Chromium 中无法可靠 abort用户点“停止生成”按钮后端其实还在狂吐 token前端却已放弃监听第三也是最关键的一点对话状态管理无法下沉到渲染进程。Agent 需要维护 conversation_id、message_history、tool_calls 等状态这些数据若只存在 Vue 组件的ref里一旦页面刷新或窗口崩溃整个对话就灰飞烟灭。而主进程是 Electron 的“永生心脏”它不随窗口关闭而销毁天然适合做状态中枢。我的方案是所有与 FastAPI 的通信全部收口到主进程渲染进程只通过ipcRenderer.invoke()发起请求主进程再用node-fetch或axios调用 FastAPI并将流式响应转换为 IPC 事件逐帧推送。这样做的好处是立体的安全可控主进程调用 FastAPI 不受 CORS 限制http://localhost:8000和file://协议完全畅通精准中断主进程拿到AbortController实例后可同时向 FastAPI 发送Connection: close头并清理本地流式 reader确保后端立即终止状态持久化主进程用Map或轻量级数据库如lowdb缓存每个conversation_id对应的完整对话历史渲染进程只需传id主进程自动注入上下文错误兜底当 FastAPI 进程意外退出主进程能捕获ECONNREFUSED错误向渲染进程发送统一错误事件而非让前端陷入 loading 死循环。具体 IPC 协议设计如下渲染进程发起ipcRenderer.invoke(agent:start-stream, { conversationId: conv_abc123, message: 你好 })主进程处理创建AbortController构造带Accept: text/event-stream头的请求监听 FastAPI 的 SSE 流流式转发每收到一个data: {token: 世}事件主进程立即webContents.send(agent:stream-token, { conversationId, token: 世 })中断指令渲染进程发ipcRenderer.send(agent:abort, { conversationId })主进程调用abortController.abort()并清理资源状态同步每次agent:start-stream成功后主进程自动更新conversationMap.get(conversationId).history.push({ role: user, content: 你好 })。提示不要在主进程中直接require(axios)Electron 主进程默认不启用 Node.js 的 CommonJS 模块解析。务必在main.js的createWindow前设置webPreferences: { nodeIntegration: true, contextIsolation: false }开发期或更安全的方案——用preload.js暴露一个封装好的 IPC 通道将node-fetch封装在 preload 中供渲染进程安全调用。后者是生产环境唯一推荐方式。3. FastAPI 的流式接口不是“加个 yield 就完事”而是异步生命周期的精密编排FastAPI 的StreamingResponse常被简化为“返回一个生成器”但 Agent 对话场景下这远远不够。一个健壮的流式/v1/chat接口必须同时解决四个硬性问题连接保活、上下文注入、中断响应、错误熔断。我见过太多项目FastAPI 接口在 Postman 里测试流式正常一接入 Electron 就卡顿或断连根源全在异步协程调度和流式 reader 的资源管理上。先看基础骨架。一个仅支持单次流式返回的接口长这样from fastapi import FastAPI, Request, Depends from fastapi.responses import StreamingResponse import asyncio import json app FastAPI() async def fake_llm_stream(prompt: str): for word in [Hello, , , world, !]: await asyncio.sleep(0.5) # 模拟 LLM 生成延迟 yield fdata: {json.dumps({token: word})}\n\n app.post(/v1/chat) async def chat_stream(request: Request): body await request.json() prompt body.get(message, ) return StreamingResponse( fake_llm_stream(prompt), media_typetext/event-stream, headers{Cache-Control: no-cache, Connection: keep-alive} )这段代码在纯 HTTP 场景下能跑通但放到 Electron Agent 场景中它会暴露三个致命缺陷无上下文没传conversation_id无法从 Redis 或内存 Map 中加载历史消息不可中断fake_llm_stream是纯生成器request.is_disconnected()在流式响应中无法实时检测客户端断连无错误隔离如果 LLM 调用超时或返回格式错误整个流式响应会抛出未捕获异常导致连接直接关闭。真正的生产级接口必须重构为异步任务驱动。我的标准实现如下from fastapi import FastAPI, Request, BackgroundTasks, Depends from fastapi.responses import StreamingResponse from typing import AsyncGenerator, Dict, Any import asyncio import json import logging from app.agent import AgentExecutor # 自研 Agent 执行器 from app.memory import ConversationMemory # 对话记忆管理器 logger logging.getLogger(__name__) app.post(/v1/chat) async def chat_stream( request: Request, background_tasks: BackgroundTasks, memory: ConversationMemory Depends(get_memory), # 依赖注入记忆实例 ): try: body await request.json() conversation_id body.get(conversation_id) or generate_id() user_message body.get(message, ).strip() if not user_message: raise ValueError(Message cannot be empty) # 1. 加载上下文从 memory 中获取该 conversation_id 的完整历史 history await memory.get_history(conversation_id) # 2. 初始化 Agent 执行器含工具调用、记忆更新等逻辑 agent AgentExecutor( model_nameqwen2-7b, tools[SearchTool(), CodeInterpreterTool()], memorymemory, conversation_idconversation_id ) # 3. 创建流式生成器内嵌中断检测 async def stream_generator() - AsyncGenerator[str, None]: try: # Agent 执行是异步迭代器yield 每个 token async for token in agent.astream(user_message, historyhistory): # 关键实时检测客户端是否断开 if await request.is_disconnected(): logger.info(fClient disconnected for conversation {conversation_id}) break yield fdata: {json.dumps({token: token, conversation_id: conversation_id})}\n\n # 4. 流结束时主动推送结束标记非必需但便于前端清理 yield fdata: {json.dumps({event: stream_end, conversation_id: conversation_id})}\n\n except asyncio.CancelledError: logger.info(fStream cancelled for conversation {conversation_id}) # Agent 执行器需支持 cancel 方法清理正在运行的工具调用 await agent.cancel() yield fdata: {json.dumps({event: aborted, conversation_id: conversation_id})}\n\n except Exception as e: logger.error(fStream error for {conversation_id}: {e}) yield fdata: {json.dumps({error: str(e), conversation_id: conversation_id})}\n\n # 5. 返回流式响应并注册后台任务清理资源 response StreamingResponse( stream_generator(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no # Nginx 关键头禁用缓冲 } ) # 6. 注册后台清理任务如释放 GPU 显存、关闭数据库连接 background_tasks.add_task(agent.cleanup) return response except Exception as e: logger.error(fChat endpoint error: {e}) return JSONResponse( status_code400, content{error: Invalid request or server error} )这个实现的关键进化点在于request.is_disconnected()的主动轮询这是 FastAPI 1.0 提供的官方方法必须在yield前调用否则无法感知 Electron 渲染进程的AbortSignalasyncio.CancelledError的捕获与响应当主进程调用abortController.abort()时FastAPI 会向协程抛出此异常必须在此处调用agent.cancel()否则模型推理仍在后台运行X-Accel-Buffering: no头如果你用 Nginx 反向代理 FastAPI此头强制 Nginx 不缓冲 SSE 数据否则前端永远收不到首包BackgroundTasks的资源回收Agent 执行可能占用 GPU 显存、数据库连接池必须在流结束后显式释放否则内存泄漏指数级增长。注意AgentExecutor必须是完全异步的。我曾用threading.Thread包裹同步 LLM SDK结果整个 FastAPI 事件循环被阻塞所有并发请求排队等待。正确做法是使用llama-cpp-python的AsyncLlama或transformers的pipeline(..., device_mapauto)配合asyncio.to_thread()将 CPU 密集型操作移交线程池。4. 渲染进程的流式渲染不是“拼字符串”而是 DOM 更新节奏的毫秒级控制当 token 以 50ms 间隔涌来前端渲染的瓶颈往往不在网络而在 DOM 操作本身。我最初用 Vue 的v-for渲染tokens数组结果发现每新增一个 token 就触发一次完整的虚拟 DOM diff当累积到 200 token 时页面明显卡顿。后来改用原生textContent token性能飙升但又引发新问题中文标点如“。”、“”后需要自动换行而纯文本追加无法控制排版。最终方案是用span元素逐个插入 token并利用requestIdleCallback批量合并 DOM 更新。核心思路是绝不让每个 token 都触发一次 DOM 插入。而是建立一个缓冲队列每 16ms约 1 帧或队列长度达 5 个 token 时批量创建span并插入到目标div中。Vue 版本实现如下template div classchat-content refcontentRef/div /template script setup import { ref, onMounted, onUnmounted } from vue import { ipcRenderer } from electron const contentRef ref(null) const tokenQueue ref([]) let animationFrameId null // 1. IPC 事件监听接收主进程推送的 token ipcRenderer.on(agent:stream-token, (event, payload) { if (payload.conversationId currentConversationId.value) { tokenQueue.value.push(payload.token) // 立即调度渲染但不立即执行 if (!animationFrameId) { animationFrameId requestAnimationFrame(renderTokens) } } }) // 2. 批量渲染函数 const renderTokens () { if (tokenQueue.value.length 0) return const fragment document.createDocumentFragment() const tokensToRender tokenQueue.value.splice(0, 5) // 每次最多渲染 5 个 tokensToRender.forEach(token { const span document.createElement(span) span.textContent token // 关键样式继承父容器字体避免重排 span.style.cssText display: inline; font-size: inherit; line-height: inherit; fragment.appendChild(span) }) contentRef.value.appendChild(fragment) // 继续调度直到队列为空 if (tokenQueue.value.length 0) { animationFrameId requestAnimationFrame(renderTokens) } else { animationFrameId null } } // 3. 中断处理清空队列重置 DOM const handleAbort () { tokenQueue.value [] if (animationFrameId) { cancelAnimationFrame(animationFrameId) animationFrameId null } // 可选添加“已中断”提示 const span document.createElement(span) span.textContent [已中断] span.className abort-tip contentRef.value.appendChild(span) } onMounted(() { ipcRenderer.on(agent:stream-end, handleStreamEnd) ipcRenderer.on(agent:aborted, handleAbort) }) onUnmounted(() { ipcRenderer.removeAllListeners(agent:stream-token) ipcRenderer.removeAllListeners(agent:stream-end) ipcRenderer.removeAllListeners(agent:aborted) }) /script这个方案解决了三大痛点性能requestAnimationFrame确保渲染与屏幕刷新率同步避免强制同步布局Layout Thrashing可中断性handleAbort清空tokenQueue并取消动画帧保证用户点击“停止”后后续 token 绝不渲染排版可控每个span可独立添加 CSS 类如.punctuation { margin-right: 2px; }对中文标点、英文空格、代码块等做精细化样式控制。但更深层的挑战是“流式渲染的语义完整性”。用户看到“今天天气真好啊”如果最后一个“啊”字被中断剩下“今天天气真好”就变成了完整句子体验割裂。我的解法是引入token 缓冲区Token Buffer在 Agent 执行器中不直接 yield 单个 token而是 accumulate 到完整词语或标点后再 yield。例如LLM 输出序列是[今, 天, 天, 气, 真, 好, 啊]Agent 层将其聚合成[今天, 天气, 真好, 啊]四个语义单元再逐个流式推送。这样即使中断也大概率停在词语边界而非字中间。实现依赖于 LLM tokenizer 的decode方法对input_ids做分词后缀匹配细节此处略去但这是提升流式体验质感的核心技巧。5. 对话状态管理不是“存个数组”而是跨进程、跨重启的会话生命周期治理Agent 对话的“状态”远比想象中复杂。它不只是[{role: user, content: hi}, {role: assistant, content: hello}]这样的消息数组。一个真实 Agent 的状态至少包含五层对话元数据conversation_id、created_at、last_active_at、is_archived消息历史用户/助手/工具调用的完整时间序记录含tool_calls、tool_responses字段短期记忆Working Memory当前对话中临时提取的实体、数值、待确认事项如“用户说要订明天下午三点的会议室” → 记忆槽位{meeting_time: 2024-06-15T15:00:00}长期记忆Long-term Memory用户偏好、常用联系人、知识库引用片段需持久化到 SQLite 或向量数据库执行上下文当前正在运行的工具进程 ID、API 调用凭证、文件上传临时路径。Electron FastAPI 架构下这五层状态必须分层存储、分层同步元数据 消息历史存于主进程内存Mapstring, Conversation因为访问最频繁且需实时响应 IPC短期记忆与消息历史绑定存在同一Conversation对象中随流式响应动态更新长期记忆存于app/storage/memory.dbSQLite由主进程定期写入避免内存泄漏执行上下文存于 FastAPI 进程的asyncio.Queue或 Redis因为涉及外部服务调用需跨请求保持。我的状态管理模块ConversationManager核心代码如下// main/conversation-manager.ts import { app, BrowserWindow } from electron import { promises as fs } from fs import path from path import sqlite3 from sqlite3 import { open } from sqlite export interface Message { id: string role: user | assistant | tool content: string tool_calls?: Array{ name: string; args: Recordstring, any } tool_response?: string timestamp: number } export interface Conversation { id: string title: string messages: Message[] working_memory: Recordstring, any created_at: number last_active_at: number is_archived: boolean } class ConversationManager { private conversations: Mapstring, Conversation new Map() private db: any null private readonly DB_PATH path.join(app.getPath(userData), memory.db) constructor() { this.initDB() } private async initDB() { this.db await open({ filename: this.DB_PATH, driver: sqlite3.Database }) await this.db.exec( CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, last_active_at INTEGER, is_archived BOOLEAN DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT, role TEXT, content TEXT, tool_calls TEXT, tool_response TEXT, timestamp INTEGER, FOREIGN KEY (conversation_id) REFERENCES conversations (id) ); ) } // 创建新对话主进程调用 createNew(): Conversation { const id conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)} const conv: Conversation { id, title: 新对话, messages: [], working_memory: {}, created_at: Date.now(), last_active_at: Date.now(), is_archived: false } this.conversations.set(id, conv) return conv } // 获取对话IPC 响应 get(id: string): Conversation | undefined { return this.conversations.get(id) } // 更新最后活跃时间每次流式开始时调用 touch(id: string) { const conv this.conversations.get(id) if (conv) { conv.last_active_at Date.now() this.conversations.set(id, conv) } } // 持久化到 SQLite后台任务避免阻塞 IPC async persistToDB(id: string) { const conv this.conversations.get(id) if (!conv) return await this.db.run( INSERT OR REPLACE INTO conversations VALUES (?, ?, ?, ?, ?), [conv.id, conv.title, conv.created_at, conv.last_active_at, conv.is_archived] ) // 批量插入消息避免 N 次 SQL const stmt await this.db.prepare( INSERT OR REPLACE INTO messages VALUES (?, ?, ?, ?, ?, ?, ?) ) for (const msg of conv.messages) { await stmt.run([ msg.id, conv.id, msg.role, msg.content, msg.tool_calls ? JSON.stringify(msg.tool_calls) : null, msg.tool_response || null, msg.timestamp ]) } await stmt.finalize() } // 应用启动时从 DB 恢复冷启动关键 async restoreFromDB() { const rows await this.db.all(SELECT * FROM conversations WHERE is_archived 0) for (const row of rows) { const messages await this.db.all( SELECT * FROM messages WHERE conversation_id ? ORDER BY timestamp, [row.id] ) this.conversations.set(row.id, { id: row.id, title: row.title, messages: messages.map(m ({ ...m, tool_calls: m.tool_calls ? JSON.parse(m.tool_calls) : undefined, timestamp: m.timestamp })), working_memory: {}, // 短期记忆不持久化 created_at: row.created_at, last_active_at: row.last_active_at, is_archived: row.is_archived }) } } } export const conversationManager new ConversationManager()这个设计的关键决策点在于内存优先磁盘兜底所有高频读写操作都在内存Map中完成persistToDB作为后台任务异步执行避免 IPC 响应被 SQLite 写入阻塞冷启动恢复app.whenReady()后立即调用restoreFromDB()确保 Electron 重启后用户能看到上次未关闭的对话列表短期记忆不持久化working_memory是瞬态的只存在于内存对象中符合其“短期”定位避免序列化复杂对象到磁盘消息分表存储conversations和messages分离支持按对话 ID 快速查询也支持全局消息搜索如“查找所有含‘会议’的消息”。实操心得SQLite 的PRAGMA journal_mode WAL必须开启否则多线程写入时会出现database is locked错误。在initDB()中加入await this.db.exec(PRAGMA journal_mode WAL)即可。另外app.getPath(userData)是 Electron 应用的专属数据目录Windows 在%APPDATA%\YourApp\macOS 在~/Library/Application Support/YourApp/Linux 在~/.config/YourApp/绝对不要硬编码路径。6. 从零搭建可运行的最小闭环Electron 主进程 FastAPI 后端 Vue 渲染进程联动验证理论讲完现在用一个可立即运行的最小 Demo 验证整个链路。这个 Demo 不依赖任何 LLM用纯模拟逻辑验证流式、中断、状态管理全流程。所有代码均可复制粘贴无需额外配置。步骤 1初始化 FastAPI 后端backend/main.pypip install fastapi uvicorn python-multipart# backend/main.py from fastapi import FastAPI, Request, BackgroundTasks from fastapi.responses import StreamingResponse import asyncio import json import time app FastAPI() # 模拟内存中的对话状态实际项目替换为 ConversationManager conversations {} app.post(/v1/chat) async def chat_stream(request: Request, background_tasks: BackgroundTasks): try: body await request.json() conv_id body.get(conversation_id, fconv_{int(time.time())}) user_msg body.get(message, Hello) # 初始化或获取对话状态 if conv_id not in conversations: conversations[conv_id] {history: [], last_active: time.time()} conversations[conv_id][last_active] time.time() conversations[conv_id][history].append({role: user, content: user_msg}) # 模拟 Agent 流式生成 async def stream(): words [Hello, , , this , is , a , streaming , response, !] for i, word in enumerate(words): if await request.is_disconnected(): break # 模拟 token 级别延迟 await asyncio.sleep(0.3) yield fdata: {json.dumps({token: word, conversation_id: conv_id, index: i})}\n\n # 结束标记 yield fdata: {json.dumps({event: end, conversation_id: conv_id})}\n\n return StreamingResponse( stream(), media_typetext/event-stream, headers{Cache-Control: no-cache, Connection: keep-alive} ) except Exception as e: return {error: str(e)} if __name__ __main__: import uvicorn uvicorn.run(app, host127.0.0.1, port8000)步骤 2Electron 主进程main.jsnpm init -y npm install electron --save-dev// main.js const { app, BrowserWindow, ipcMain, dialog } require(electron) const path require(path) const { exec } require(child_process) function createWindow() { const win new BrowserWindow({ width: 1000, height: 700, webPreferences: { nodeIntegration: true, contextIsolation: false, preload: path.join(__dirname, preload.js) } }) win.loadFile(index.html) win.webContents.openDevTools() } app.whenReady().then(createWindow) // 启动 FastAPI 后端开发期自动 let apiProcess app.on(ready, () { apiProcess exec(cd backend uvicorn main:app --host 127.0.0.1 --port 8000, { cwd: __dirname }, (error, stdout, stderr) { if (error) { console.error(FastAPI start error: ${error}) dialog.showErrorBox(Backend Error, Failed to start FastAPI: ${error.message}) } }) }) // IPC 处理流式请求 ipcMain.handle(agent:start-stream, async (event, { conversationId, message }) { const controller new AbortController() const signal controller.signal try { const response await fetch(http://127.0.0.1:8000/v1/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ conversation_id: conversationId, message }), signal }) if (!response.ok) throw new Error(HTTP ${response.status}) const reader response.body.getReader() const decoder new TextDecoder() // 逐块读取 SSE 数据 while (true) { const { done, value } await reader.read() if (done) break const chunk decoder.decode(value) const lines chunk.split(\n) for (const line of lines) { if (line.startsWith(data: )) { try { const data JSON.parse(line.slice(6)) event.sender.send(agent:stream-token, { ...data, conversationId }) } catch (e) { console.warn(Invalid SSE data:, line) } } } } } catch (err) { if (err.name AbortError) { event.sender.send(agent:aborted, { conversationId }) } else { event.sender.send(agent:error, { conversationId, error: err.message }) } } finally { reader.releaseLock() } }) ipcMain.on(agent:abort, (event, { conversationId }) { // 实际项目中这里会向 FastAPI 发送中断信号 // 当前 Demo 仅通知前端 event.sender.send(agent:aborted, { conversationId }) })步骤 3预加载脚本preload.js// preload.js const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(api, { startStream: (payload) ipcRenderer.invoke(agent:start-stream, payload), abortStream: (payload) ipcRenderer.send(agent:abort, payload), onStreamToken: (callback) ipcRenderer.on(agent:stream-token, callback), onAborted: (callback) ipcRenderer.on(agent:aborted, callback) })步骤 4Vue 渲染进程index.html 内联 Vue!-- index.html -- !DOCTYPE html html head meta charsetUTF-8 titleElectron FastAPI Stream Demo/title style .chat-container { padding: 20px; font-family: -apple-system, BlinkMacSystemFont, Segoe UI; } .input-area { display: flex; margin-top: 10px; } .input-area input { flex: 1; padding: 10px; font-size: 16px; } .input-area button { padding: 10px 20px; font-size: 16px; margin-left: 10px; } .chat-content { min-height: 400px; border: 1px solid #eee; padding: 15px; white-space: pre-wrap; } /style /head body div idapp div classchat-container h2Stream Demo/h2 div classchat-content idcontent/div div classinput-area input v-modelinputMessage keyup.entersend placeholderType and press Enter... / button clicksendSend/button button clickabortAbort/button /div /div /div script srchttps://unpkg.com/vue3/dist/vue.global.js/script script const { createApp, ref, onMounted, onUnmounted } Vue createApp({ setup() { const inputMessage ref() const contentEl document.getElementById(content) const conversationId conv_demo_ Date.now() const send async () { if (!inputMessage.value.trim()) return // 清空内容区 contentEl.innerHTML // 发送流式请求 try { await window.api.startStream({ conversationId, message: inputMessage.value }) } catch (e) { contentEl.textContent Error: e.message } inputMessage.value } const abort () { window.api.abortStream({ conversationId }) } // 监听流式 token const onToken (event, payload) { if (payload.conversationId conversationId) { const span document.createElement(span) span.textContent payload.token contentEl.appendChild(span) } } const onAborted () { const span document.createElement(span) span.textContent [ABORTED] span.style.color #999 contentEl.appendChild(span) } onMounted(() { window.api.onStreamToken(onToken) window.api.onAborted(onAborted) }) onUnmounted(() { window.api.onStreamToken(() {}) window.api.onAborted(() {}) }) return { inputMessage, send, abort } } }).mount(#app) /script /body /html运行验证终端 1cd backend python main.py启动 FastAPI终端 2npm start启动 Electron确保package.json中main: main.js在输入框输入任意文字点击 Send观察内容区是否逐字渲染点击 Abort检查是否立即停止并显示[ABORTED]这个最小闭环验证了Electron 主进程成功调用 FastAPI 流式接口SSE 数据被正确解析并转发给渲染进程渲染进程实现 token 级别逐字渲染