前言

在当今 AI 应用蓬勃发展的时代,与大语言模型(LLM)的交互已成为很多应用的核心功能之一。流式输出(Server-Sent Events,SSE)能够带来更佳的用户体验——用户无需等待模型生成完整回复,而是像打字一样逐字看到AI的思考过程。

今天要分析的是一个用于封装大模型流式输出的 Vue 3 Composable:useLLMChat。它优雅地解决了流式聊天中的各种问题,让我们一起来深入理解它的设计哲学。

功能概述

基本功能

useLLMChat 是一个 Vue 3 的组合式函数(Composable),主要提供以下核心能力:

  1. 流式响应处理 - 支持 SSE 协议,实时接收并处理大模型的流式输出
  2. 多种聊天模式 - 支持直接聊天和会话聊天两种模式
  3. 生命周期管理 - 提供 loading、streaming、error 等状态
  4. 中断控制 - 支持随时停止流式输出
  5. 状态重置 - 提供重置功能,清空所有状态

核心 API

export function useLLMChat(options: UseLLMChatOptions = {}) {
  return {
    isLoading: readonly(isLoading),      // 是否正在加载
    isStreaming: readonly(isStreaming),  // 是否正在流式输出
    error: readonly(error),              // 错误信息
    fullResponse: readonly(fullResponse), // 完整响应内容
    chunkCount: readonly(chunkCount),    // 收到的 chunk 数量
    chat,                                // 直接聊天方法
    conversationChat,                    // 会话聊天方法
    stop,                                // 停止流式输出
    reset,                               // 重置状态
  };
}

使用示例

const { 
  isLoading, 
  isStreaming, 
  fullResponse, 
  chat, 
  stop, 
  reset 
} = useLLMChat({
  onSuccess: () => console.log('Chat completed'),
  onError: (err) => console.error('Error:', err),
  onComplete: () => console.log('Done'),
});

// 直接调用
const response = await chat([
  { role: 'user', content: '你好,请介绍一下自己' }
], {
  model: 'gpt-3.5-turbo',
  provider: 'openai'
});

核心原理

SSE 数据流解析

useLLMChat 的核心在于 createSSEReader 方法,它实现了对 SSE 流的完整解析:

async function* createSSEReader(stream: ReadableStream<Uint8Array>) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const result = await reader.read();
    if (result.done) break;

    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (line.startsWith('event:')) {
        currentEvent = line.slice(6).trim();
        continue;
      }
      if (line.startsWith('data:')) {
        const dataContent = line.slice(5).trim();
        yield { event: currentEvent, data: dataContent };
      }
    }
  }
}

这段代码有以下几个精妙之处:

  1. 流式解码 - 使用 { stream: true } 参数,避免 UTF-8 多字节字符被截断
  2. 缓冲区管理 - 将未处理完的剩余数据保留到下一次循环
  3. 事件解析 - 同时解析 eventdata 字段,支持多种事件类型

状态管理策略

const isLoading = ref(false);      // 请求是否开始
const isStreaming = ref(false);    // 是否正在流式输出
const error = ref<string | null>(null);   // 错误状态
const fullResponse = ref('');       // 完整响应
const chunkCount = ref(0);          // chunk 计数

状态设计遵循以下原则:

  • isLoading vs isStreaming - loading 表示请求发出了,streaming 表示正在接收数据
  • readonly 暴露 - 防止外部直接修改状态,保证内部一致性
  • fullResponse - 累加式的响应存储,方便展示完整内容

两种聊天模式的差异

特性 chat 方法 conversationChat 方法
用途 直接调用 LLM API 维护会话上下文的聊天
配置 每次调用传入 从 llmConfig 读取
中断支持 使用 AbortController
回调参数 通过 options 传入 通过 params 传入

设计理念

为什么这样设计?

  1. 统一的错误处理

    } catch (err: any) {
      const errorMessage = err.message || 'Chat failed';
      error.value = errorMessage;
      options.onError?.(errorMessage);      // 调用全局错误回调
      params.onError?.(errorMessage);       // 调用方法级错误回调
      throw err;
    }
    

    错误会同时触发全局和方法级的回调,让调用方有最大的灵活性。

  2. 中断机制的优雅实现

    abortController = new AbortController();
    
    for await (const { event, data } of reader) {
      if (!isStreaming.value) {  // 检查中断标志
        break;
      }
      // ...处理数据
    }
    
    function stop() {
      if (abortController) {
        abortController.abort();  // 触发 AbortError
      }
      isStreaming.value = false;
    }
    

    使用 isStreaming 标志位 + AbortController 双重保障,确保中断及时生效。

  3. 资源清理的 finally 块

    finally {
      isLoading.value = false;
      isStreaming.value = false;
      abortController = null;
      options.onComplete?.();
    }
    }
    

    无论成功还是失败,都会在 finally 中重置状态,这是非常规范的做法。

优缺点分析

优点:

  • 状态设计清晰,readonly 防止意外修改
  • 错误处理完善,支持多级回调
  • 支持中断流式输出,用户体验好
  • 代码结构规范,易于维护和扩展

缺点/局限:

  • createSSEReader 是内部方法,无法复用于其他场景
  • 缺乏请求超时控制
  • 缺少重试机制

最佳实践

典型场景:AI 辅助生成文章摘要

在实际项目中,useLLMChat 最常见的用途之一就是 AI 辅助内容生成。下面是一个真实的业务场景——自动生成经验文章的摘要:

import { watch } from 'vue';
import { useLLMChat } from '@/composables/useLLMChat';
import { llmConfig } from '@/utils/llmConfig';

// 1. 初始化 hook,传入回调
const llmChatState = useLLMChat({
  onComplete: () => {
    console.log('LLM生成摘要完成');
  },
});

// 2. 定义生成摘要的方法
const handleGenerateSummary = async (content: string) => {
  // 每次调用前先重置状态
  llmChatState.reset();

  // 清理 Markdown 内容,移除图片和格式标记
  const cleanContent = content
    .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '')
    .replace(/[#*`]/g, '')
    .slice(0, 2000);

  const messages = [
    {
      role: 'system',
      content: '你是一个专业的经验文章摘要生成助手。请根据提供的经验文章内容生成一段简洁的摘要,大约50-100字。',
    },
    {
      role: 'user',
      content: `请为以下经验文章生成摘要:\n\n${cleanContent}`,
    },
  ];

  // 3. 调用 chat 方法,传入配置
  await llmChatState.chat(messages, {
    baseUrl: llmConfig.value.baseUrl,
    model: llmConfig.value.model,
    apiKey: llmConfig.value.apiKey,
    provider: llmConfig.value.provider,
    temperature: 0.7,
    maxTokens: 200,
  });
};

// 4. 通过 watch 监听流式输出,实时更新 UI
watch(
  () => llmChatState.fullResponse.value,
  (newVal) => {
    if (newVal) {
      form.value.summary = newVal;
    }
  }
);

这个示例展示了实际开发中的几个关键点:

注意事项

  1. 每次调用前务必调用 reset()

    在实际使用中,每次发起新的聊天请求前,必须调用 reset() 重置状态,否则 fullResponse 会累积之前的输出。

    llmChatState.reset();  // 清空之前的状态
    await llmChatState.chat(messages, config);
    
  2. 使用 watch 监听流式输出

    chat 方法返回的是完整响应,而非流式过程中的数据。如果需要实时显示 AI 正在输入的内容,应该使用 watch 监听 fullResponse

    watch(
      () => llmChatState.fullResponse.value,
      (newVal) => {
        if (newVal) {
          // 实时更新 UI
          currentContent.value = newVal;
        }
      }
    );
    
  3. 中断后需要手动重置

    调用 stop() 后,isStreaming 会变为 false,但 fullResponse 会保留已接收的内容。如果需要清空,请调用 reset()

  4. 处理 AbortError

    } catch (err: any) {
      if (err.name === 'AbortError') {
        return fullResponse.value;  // 中断时返回已接收的内容
      }
      // ...其他错误处理
    }
    

    中断是正常行为,不是错误,需要特殊处理。

总结

useLLMChat 是一个设计精良的 Vue 3 Composable,它:

  • 完整实现了 SSE 协议的解析
  • 提供了清晰的状态管理和错误处理
  • 支持流式输出的中断控制
  • 代码结构规范,易于理解和扩展

通过分析这个 Hook,我们可以学到很多实用的技巧:流式解码、缓冲区管理、AbortController 中断、finally 资源清理等。这些技术在处理任何流式数据时都非常有用。

希望这篇文章能帮助你更好地理解大模型流式输出的实现原理。如果有任何问题,欢迎在评论区讨论!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐