# 把 AI 回答流式输出到每个屏幕

> 聊天机器人、copilot 与 AI 代理：边写边出现的回答，送到每个标签页和设备，刷新后接着写。

Source: https://hibira.io/zh-cn/use-cases/ai-streaming

聊天机器人、copilot 与 AI 代理要让人觉得快，靠的是模型一边写、页面一边出现答案。大多数应用用提问的那一个 HTTP 响应来流式输出。用户刷新页面、打开第二个标签页、换到手机，或者同事也想看同一个回答时，这就不够用了。很长的回答也可能超过 serverless 函数的执行时间上限。把回答发布到频道，就能把模型的工作和浏览器的连接分开：每个订阅中的页面看到同一段文字，断线的页面回来时从频道历史补上。常见的技术栈都有我们的开源包负责分批、排序和补数据，你只要加几行，不用自己写。

## 用户得到什么

- 答案一个字一个字出现，不用等整段回答写完。
- 每个标签页、每台设备，以及共享聊天里的每个人，都看到同一个回答。
- 刷新或断线后，回答继续写下去，只补上漏掉的文字，不会从头再来。
- “停止”按钮；用户关掉标签页后回答也会继续写，回来时结果已经好了。

![演示助手：同一个回答同时流式输出到笔记本和手机，手机重新连接后自动补上进度。](https://hibira.io/portal/articles/demo-ai-streaming-zh-CN.webp)

## 使用的功能

- **私有频道**，只有能进入这段对话的人才能读到回答，由你的授权端点检查。
- **频道历史**（`pusher:rewind`，Hobby 及以上套餐，在各 app 的设置中开启），重新连接的页面会收到它漏掉的事件。
- **我们的包**，支持 Vercel AI SDK、React、Vue、Nuxt 和 Laravel。它们把 token 打包成小于 Free 4 KB 上限的事件、编号，再按顺序组装回来。
- **你自己的模型和 API 密钥**：任何能流式输出文字的模型都可以。Hibira 只负责把文字送到用户面前。

```text
sequence: 你的服务器只写一次回答，Hibira 把它送到每个打开的页面
浏览器 | 你的 API | *Hibira
1 > 3: 订阅 private-ai-chat-7
1 > 2: POST /api/chat
2 >> 1: 202，回答在后台继续写
2 > 3: 编号的分批事件 | 每 50–100 ms 一批
3 > 1: 每个页面都出现文字
1 > 3: 重新连接后：pusher:rewind
3 >> 1: 漏掉的事件
```

## 按技术栈选包

| 你用的是 | 包 | 浏览器端 |
| --- | --- | --- |
| Vercel AI SDK `useChat` | [@hibira-io/ai-sdk](https://hibira.io/zh-cn/guides/ai-sdk) | `HibiraChatTransport` |
| Anthropic、OpenAI 或 Gemini SDK，搭配 React 或 Vue | [@hibira-io/react](https://hibira.io/zh-cn/guides/react)、[@hibira-io/vue](https://hibira.io/zh-cn/guides/vue) | `useStream()` |
| Laravel AI SDK 代理 | [hibira-io/laravel](https://hibira.io/zh-cn/guides/laravel) | `useAgentStream()` |
| 其他语言或框架 | 任何 Pusher 服务端库 | pusher-js |

## Vercel AI SDK：给 useChat 的 transport

`useChat()` 照原来的用法，只是回答改从频道送来，而不是 HTTP 响应。浏览器端：

```tsx
import { useChat } from '@ai-sdk/react';
import { HibiraChatTransport } from '@hibira-io/ai-sdk';

const transport = new HibiraChatTransport({ pusher, api: '/api/chat' });

export function Chat({ id, initialMessages }) {
  const { messages, sendMessage, status } = useChat({ id, messages: initialMessages, transport, resume: true });
  // render messages as usual
}
```

API route 立即响应，并在后台发布模型的流：

```ts
import { after } from 'next/server';
import { streamText, convertToModelMessages } from 'ai';
import { publishUIMessageStream } from '@hibira-io/ai-sdk/server';

export async function POST(req: Request) {
  const { id, messages, runId } = await req.json();
  const result = streamText({ model, messages: await convertToModelMessages(messages) });
  after(() =>
    publishUIMessageStream(pusher, {
      chatId: id,
      runId,
      stream: result.toUIMessageStream({ originalMessages: messages, onFinish: ({ messages }) => saveChat(id, messages) }),
    }),
  );
  return Response.json({ runId }, { status: 202 });
}
```

transport 会订阅 `private-ai-chat-{chatId}`，所以在授权端点里只允许聊天的所有者订阅。加上 `resume: true`，刷新后的页面会接着显示还在写的回答。

## React 或 Vue，搭配任何模型 SDK

`streamToChannel()` 直接接收 Anthropic、OpenAI 或 Gemini SDK 返回的流：

```js
import { streamToChannel } from '@hibira-io/react/server';

export async function POST(req) {
  const { conversationId, question } = await req.json();
  const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
  const id = crypto.randomUUID();
  streamToChannel(pusher, `private-conversation-${conversationId}`, stream, { id }).catch(console.error);
  return Response.json({ id });
}
```

```jsx
import { useStream } from '@hibira-io/react';

function Answer({ conversationId }) {
  const { text, done, error } = useStream(`private-conversation-${conversationId}`, { rewind: true });
  return <p aria-busy={!done}>{text}{error && <em> ({error})</em>}</p>;
}
```

`@hibira-io/vue` 也有同样的 `useStream()`，Nuxt 会自动帮你导入。要加“停止”按钮，就把 `AbortController` 的 `signal` 传给 `streamToChannel()`，在你的停止 route 里调用 abort。

## Laravel AI SDK 代理

把代理放入队列，并把它的事件广播到用户的私有频道；文字、推理、工具调用和审批请求都会按顺序送达，超过套餐事件大小的会自动拆开：

```php
use Hibira\Laravel\Ai\AgentChannel;

(new SalesCoach)->broadcastOnQueue($request->input('prompt'), AgentChannel::user($request->user()));
```

```jsx
const { text, status, toolCalls, approvals } = useAgentStream(`private-hibira-ai.user.${user.id}`, { rewind: true });
```

`status` 会是 `streaming`、`awaiting_approval`、`done` 或 `error`，页面可以据此显示代理正在做什么。

## 代理步骤与审批

要显示代理的每个步骤、在执行有风险的工具前先问人，或让 MCP 客户端里的代理报告进度，请看 [AI 代理：实时步骤、人工审批与通知](https://hibira.io/zh-cn/use-cases/ai-agents)。两者用的是同一套频道，所以同一个页面可以同时显示流式输出中的回答和代理的步骤。

## 不用包的写法

用任何 Pusher 服务端库，每 100 ms 发送模型写好的内容并给每批编号，浏览器就能按顺序组装、发现缺漏：

```python
seq, buffer, last = 0, "", time.monotonic()
for delta in model_stream:
    buffer += delta
    if time.monotonic() - last >= 0.1 or len(buffer) >= 1000:
        pusher_client.trigger(f"private-chat-{chat_id}", "ai-delta", {"id": answer_id, "seq": seq, "text": buffer})
        seq, buffer, last = seq + 1, "", time.monotonic()
pusher_client.trigger(f"private-chat-{chat_id}", "ai-done", {"id": answer_id, "seq": seq, "text": buffer})
```

把完成的回答存进你的数据库，页面打开或重新连接时读取一次。搭配队列 worker 的同样做法，请看[长时间任务的进度](https://hibira.io/zh-cn/use-cases/job-progress)。

## 小技巧

- 每次发布算一条消息，不管有多少页面收到。按每秒 50 个 token 计算，20 秒的回答逐 token 发送约 1,000 条，每 100 ms 一批约 200 条。
- 每个事件要小于你套餐的事件大小上限（Free 为 4 KB，见[套餐与价格](https://hibira.io/zh-cn/pricing)）。包会控制在 3,500 bytes 以内，更大的会自动拆开。
- 频道历史需要 Hobby 及以上套餐。没开的话包一样能流式输出，漏掉一段的页面会标记为不完整，而不是显示坏掉的内容。
- 只有一个标签页、也不会刷新的情况，用普通的 HTTP 流式输出就够了。回答需要经得起刷新、送到多台设备或多个人，或者来自后台任务时，再用频道。
- 如果用户在长回答或代理任务完成前就关掉了 app，用[推送通知指南](https://hibira.io/zh-cn/guides/push-notifications)发一条推送。
