Skip to content

第8章 响应模块开发与升级:提升聊天机器人响应体验

等AI回复要10秒?用户早就关了页面。流式响应才是正解。

我是怕浪猫,这章解决一个用户体验的硬伤——响应速度。不是让LLM变快,而是让用户感觉快。流式响应+打字机效果,感知延迟从10秒降到0.3秒。


8.1 LLMOps 长短期记忆功能模块开发

长短期记忆架构

短期记忆(内存/Redis):当前对话的最近消息
  ↓ 摘要
长期记忆(数据库):对话摘要 + 向量化存储

短期记忆实现

python
# services/memory_service.py 扩展
class MemoryService:
    def __init__(self, redis_client=None):
        self.redis = redis_client
        self.short_term_limit = 10  # 最近10条消息
    
    def get_short_term(self, conv_id):
        """获取短期记忆(Redis缓存)"""
        if self.redis:
            cached = self.redis.get(f"conv:{conv_id}:short_term")
            if cached:
                return json.loads(cached)
        
        # Redis没有,从数据库加载
        messages = Message.query.filter_by(
            conversation_id=conv_id
        ).order_by(Message.created_at.desc()).limit(self.short_term_limit).all()
        messages.reverse()
        
        result = [{"role": m.role, "content": m.content} for m in messages]
        
        # 缓存到Redis
        if self.redis:
            self.redis.setex(
                f"conv:{conv_id}:short_term",
                3600,  # 1小时过期
                json.dumps(result)
            )
        
        return result
    
    def add_to_short_term(self, conv_id, role, content):
        """添加到短期记忆"""
        if self.redis:
            key = f"conv:{conv_id}:short_term"
            cached = self.redis.get(key)
            messages = json.loads(cached) if cached else []
            messages.append({"role": role, "content": content})
            
            # 只保留最近N条
            if len(messages) > self.short_term_limit:
                messages = messages[-self.short_term_limit:]
            
            self.redis.setex(key, 3600, json.dumps(messages))
    
    def get_combined_memory(self, conv_id):
        """获取组合记忆(摘要+短期)"""
        conv = Conversation.query.get(conv_id)
        
        messages = []
        if conv.summary:
            messages.append({"role": "system", "content": f"对话摘要:{conv.summary}"})
        
        messages.extend(self.get_short_term(conv_id))
        return messages

8.2 流式响应 vs 非流式响应:基础与应用场景

对比

维度非流式流式
用户体验等待全部生成才显示逐字显示,感知快
首字时间等全部生成完生成第一个token即显示
实现复杂度简单较复杂
上下文处理完整内容一次返回需要拼接chunks
适用场景短回答、批量处理聊天、长文本生成

非流式实现

python
# 普通API调用
response = client.chat.completions.create(
    model="gpt-4",
    messages=messages
)
return response.choices[0].message.content  # 一次性返回

流式实现

python
# 流式API调用
stream = client.chat.completions.create(
    model="gpt-4",
    messages=messages,
    stream=True  # 关键参数
)

for chunk in stream:
    if chunk.choices[0].delta.content:
        yield chunk.choices[0].delta.content

8.3 LangChain 流式响应实现

LangChain流式API

python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

llm = ChatOpenAI(model="gpt-4", streaming=True)
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个有帮助的助手"),
    ("user", "{input}")
])
chain = prompt | llm

# 同步流式
for chunk in chain.stream({"input": "解释RAG"}):
    print(chunk.content, end="", flush=True)

# 异步流式
async def stream_response():
    async for chunk in chain.astream({"input": "解释RAG"}):
        yield chunk.content

Flask集成流式响应

python
from flask import Response, stream_with_context

@chat_bp.route('/completions/stream', methods=['POST'])
def stream_completions():
    data = request.json
    
    def generate():
        messages = build_messages(data['conversation_id'], data['message'])
        
        stream = client.chat.completions.create(
            model=data.get('model', 'gpt-4'),
            messages=messages,
            stream=True
        )
        
        full_content = ""
        for chunk in stream:
            if chunk.choices[0].delta.content:
                content = chunk.choices[0].delta.content
                full_content += content
                # SSE格式输出
                yield f"data: {json.dumps({'content': content})}\n\n"
        
        # 保存完整回复
        save_message(data['conversation_id'], 'assistant', full_content)
        yield f"data: {json.dumps({'done': True})}\n\n"
    
    return Response(
        stream_with_context(generate()),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no'  # Nginx不缓冲
        }
    )

8.4 后端:队列 + 协程突破 LangGraph 限制实现流式传输

LangGraph的流式限制

LangGraph默认不直接支持SSE流式输出,需要特殊处理。

解决方案:队列桥接

python
import asyncio
from queue import Queue
from threading import Thread

class StreamBridge:
    """将LangGraph的异步流式输出桥接到Flask的SSE"""
    
    def __init__(self):
        self.queue = Queue()
        self.done = False
    
    def put(self, data):
        self.queue.put(data)
    
    def finish(self):
        self.done = True
        self.queue.put(None)  # 结束信号
    
    def __iter__(self):
        while not self.done or not self.queue.empty():
            item = self.queue.get(timeout=1)
            if item is None:
                break
            yield item

def run_agent_stream(query, bridge):
    """在独立线程中运行Agent"""
    try:
        for chunk in agent_executor.stream({"input": query}):
            if isinstance(chunk, dict) and 'output' in chunk:
                bridge.put(json.dumps({'content': chunk['output']}))
            else:
                bridge.put(json.dumps({'content': str(chunk)}))
    except Exception as e:
        bridge.put(json.dumps({'error': str(e)}))
    finally:
        bridge.finish()

@chat_bp.route('/agent/stream', methods=['POST'])
def stream_agent():
    data = request.json
    bridge = StreamBridge()
    
    # 在独立线程运行Agent
    thread = Thread(target=run_agent_stream, args=(data['message'], bridge))
    thread.start()
    
    return Response(
        stream_with_context(
            f"data: {item}\n\n" for item in bridge
        ),
        mimetype='text/event-stream'
    )

8.5 前端:fetch 获取流式事件数据 + 打字机效果

fetch流式读取

javascript
// api/chat.js 新增
export const streamChat = async (data, onChunk, onDone) => {
  const response = await fetch('/api/v1/chat/completions/stream', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  })
  
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    
    const text = decoder.decode(value)
    const lines = text.split('\n')
    
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = JSON.parse(line.slice(6))
        if (data.done) {
          onDone()
        } else if (data.content) {
          onChunk(data.content)
        }
      }
    }
  }
}

打字机效果组件

vue
<!-- components/StreamingMessage.vue -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  content: { type: String, default: '' },
  streaming: { type: Boolean, default: false }
})

const displayContent = ref('')
const cursorVisible = ref(true)

// 打字机效果:逐字显示
watch(() => props.content, (newVal) => {
  if (!props.streaming) {
    displayContent.value = newVal
    return
  }
  
  const target = newVal
  const current = displayContent.value
  
  if (target.length > current.length) {
    // 新增内容,直接追加(不做逐字动画,否则跟不上流式速度)
    displayContent.value = target
  }
})

// 光标闪烁
let cursorTimer = null
if (props.streaming) {
  cursorTimer = setInterval(() => {
    cursorVisible.value = !cursorVisible.value
  }, 500)
}
</script>

<template>
  <div class="whitespace-pre-wrap">
    {{ displayContent }}
    <span v-if="streaming && cursorVisible" class="inline-block w-2 h-4 bg-gray-300 ml-0.5">|</span>
  </div>
</template>

Pinia集成流式

javascript
// stores/chat.js 升级
const useStreaming = ref(false)

const sendMessage = async (content) => {
  loading.value = true
  messages.value.push({ role: 'user', content })
  messages.value.push({ role: 'assistant', content: '', streaming: true })
  
  const assistantIndex = messages.value.length - 1
  
  if (useStreaming.value) {
    await chatAPI.streamChat({
      message: content,
      conversation_id: currentConvId.value
    }, 
    // onChunk
    (chunk) => {
      messages.value[assistantIndex].content += chunk
    },
    // onDone
    () => {
      messages.value[assistantIndex].streaming = false
      loading.value = false
    })
  } else {
    // 普通模式
    const res = await chatAPI.sendMessage({ message: content })
    messages.value[assistantIndex].content = res.data.message
    messages.value[assistantIndex].streaming = false
    loading.value = false
  }
}

8.6 流式响应下 Token 计数与中断功能

Token计数

python
# 流式响应中统计Token
@chat_bp.route('/completions/stream', methods=['POST'])
def stream_completions():
    data = request.json
    
    def generate():
        full_content = ""
        prompt_tokens = 0
        completion_tokens = 0
        
        stream = client.chat.completions.create(
            model=data.get('model', 'gpt-4'),
            messages=messages,
            stream=True,
            stream_options={"include_usage": True}  # 包含Token统计
        )
        
        for chunk in stream:
            if chunk.choices and chunk.choices[0].delta.content:
                content = chunk.choices[0].delta.content
                full_content += content
                yield f"data: {json.dumps({'content': content})}\n\n"
            
            # 最后一个chunk包含usage
            if hasattr(chunk, 'usage') and chunk.usage:
                prompt_tokens = chunk.usage.prompt_tokens
                completion_tokens = chunk.usage.completion_tokens
        
        # 发送统计信息
        yield f"data: {json.dumps({
            'done': True,
            'usage': {
                'prompt_tokens': prompt_tokens,
                'completion_tokens': completion_tokens,
                'total_tokens': prompt_tokens + completion_tokens
            }
        })}\n\n"
        
        # 保存完整消息
        save_message(data['conversation_id'], 'assistant', full_content, 
                     completion_tokens)
    
    return Response(generate(), mimetype='text/event-stream')

中断功能

python
# 中断机制:使用Redis标记
@chat_bp.route('/completions/abort/<conversation_id>', methods=['POST'])
def abort_completions(conversation_id):
    """中断正在进行的流式响应"""
    redis_client.setex(f"abort:{conversation_id}", 60, "1")
    return success()

# 在generate中检查中断
def generate():
    for chunk in stream:
        # 检查中断标记
        if redis_client.get(f"abort:{conv_id}"):
            yield f"data: {json.dumps({'aborted': True})}\n\n"
            break
        # ... 正常处理

前端中断按钮

javascript
const abortRequest = async () => {
  if (currentConvId.value) {
    await chatAPI.abortCompletions(currentConvId.value)
    loading.value = false
  }
}

8.7 前后端接口对接与测试完善

联调检查清单

检查项预期验证方式
非流式响应正常返回完整内容curl测试
流式响应SSE逐chunk返回浏览器DevTools
打字机效果逐字显示+光标前端观察
Token统计正确计数日志验证
中断功能点击中断立即停止手动测试
长文本流式不中断5000字+测试
并发多用户同时流式压测

流式响应测试脚本

python
# tests/test_stream.py
import requests

def test_stream_response():
    response = requests.post(
        'http://localhost:5000/api/v1/chat/completions/stream',
        json={'message': '写一首关于编程的诗'},
        stream=True
    )
    
    chunks = []
    for line in response.iter_lines():
        if line:
            line = line.decode('utf-8')
            if line.startswith('data: '):
                data = json.loads(line[6:])
                if data.get('content'):
                    chunks.append(data['content'])
                if data.get('done'):
                    break
    
    full_content = ''.join(chunks)
    assert len(full_content) > 0
    print(f"收到 {len(chunks)} 个chunk,总内容长度 {len(full_content)}")

流式响应的实现不难,难的是在各种场景下稳定——网络抖动、用户中断、Agent多轮工具调用。把这些边界情况处理好,才算生产级。


本章小结

主题核心要点
长短期记忆Redis缓存短期 + DB摘要长期
流式vs非流式流式感知延迟低,实现复杂
LangChain流式stream=True + SSE
队列桥接线程+Queue突破LangGraph限制
前端打字机fetch流式读取 + 逐字显示
Token计数stream_options包含usage
中断功能Redis标记 + 前端中断按钮

觉得有用?收藏起来,下次直接照抄。

你的流式响应实现遇到过什么坑?评论区聊聊。

关注怕浪猫,下期我们搞安全——JWT认证、GitHub第三方登录、路由守卫,让LLMOps平台有安全感。

系列进度 8/23

下章预告: 第9章授权认证模块——JWT原理、GitHub OAuth、Flask-Login、前端令牌管理、路由守卫,给你的平台加上安全锁。

热爱生活,喜好美食,追求未来!