CHAT_API_DESIGN.md 14 KB

Chat API 返回内容区分设计文档

概述

本文档描述如何将 Chat API 的返回内容区分为"上文"、"引用"、"正文"、"深度思考"四个部分。

当前实现分析

现有返回结构

{ type: 'content' | 'sources' | 'historyId'; data: any }

现有问题

  1. content 类型过于笼统:混合了搜索状态、调试信息、AI正文等多种内容
  2. 缺少上文返回:历史对话被格式化后传给LLM,但没有返回给客户端
  3. 缺少深度思考:没有支持 reasoning/thinking 过程的返回

设计方案

新的返回类型定义

export type ChatStreamType = 
  | 'history'      // 上文:历史对话内容
  | 'sources'      // 引用:搜索结果来源
  | 'content'      // 正文:AI生成的回答
  | 'thinking'     // 深度思考:推理过程
  | 'status'       // 状态:搜索进度、调试信息
  | 'historyId';   // 历史ID:聊天会话ID

export interface ChatStreamChunk {
  type: ChatStreamType;
  data: any;
}

各类型详细说明

1. history - 上文

用途:返回历史对话内容,让客户端可以显示上下文

数据结构

{
  type: 'history',
  data: {
    messages: Array<{
      role: 'user' | 'assistant';
      content: string;
    }>;
  }
}

触发时机:在聊天开始时返回

2. sources - 引用

用途:返回搜索结果来源,包含文件名、内容片段、相似度分数

数据结构

{
  type: 'sources',
  data: Array<{
    fileName: string;
    content: string;
    score: number;
    chunkIndex: number;
    fileId: string;
  }>
}

触发时机:在搜索完成后、生成正文前返回

3. content - 正文

用途:返回AI生成的回答内容(纯文本)

数据结构

{
  type: 'content',
  data: string  // 流式返回的文本片段
}

触发时机:LLM生成过程中持续返回

4. thinking - 深度思考

用途:返回AI的推理过程(如Chain-of-Thought)

数据结构

{
  type: 'thinking',
  data: string  // 流式返回的思考过程
}

触发时机:在正文之前返回(如果模型支持)

5. status - 状态

用途:返回搜索进度、调试信息等

数据结构

{
  type: 'status',
  data: {
    stage: 'searching' | 'search_complete' | 'generating' | 'debug';
    message: string;
    details?: any;
  }
}

触发时机:在整个过程中穿插返回

6. historyId - 历史ID

用途:返回聊天会话ID

数据结构

{
  type: 'historyId',
  data: string  // 会话ID
}

触发时机:在聊天开始时返回

需要修改的文件

后端文件

  1. server/src/chat/chat.service.ts

    • 修改 streamChat 方法的返回类型
    • 区分不同类型的 content
    • 添加 history 类型返回
    • 添加 thinking 类型支持
  2. server/src/api/api-v1.controller.ts

    • 更新 SSE 响应格式
    • 确保所有类型都被正确处理
  3. server/src/types.ts

    • 添加新的类型定义

前端文件

  1. web/services/chatService.ts

    • 更新流式响应处理逻辑
    • 区分不同类型的 chunk
  2. web/components/ChatInterface.tsx 或相关组件

    • 更新UI渲染逻辑
    • 分别显示上文、引用、正文、深度思考

实现步骤

第一阶段:类型定义

  1. server/src/types.ts 中添加新的类型定义
  2. 更新 ChatStreamChunk 接口

第二阶段:后端实现

  1. 修改 chat.service.ts 中的 streamChat 方法

    • 添加 history 类型返回
    • 将 content 细分为 status、thinking、content
    • 保持 sources 不变
  2. 更新 api-v1.controller.ts 确保正确处理新类型

第三阶段:前端实现

  1. 更新 chatService.ts 处理新的流式响应
  2. 更新聊天界面组件,分别渲染不同类型的 chunk

兼容性考虑

  • 保持向后兼容:旧的 content 类型仍然可用
  • 新增类型是可选的,不影响现有功能
  • 前端可以逐步适配新类型

深度思考支持(健壮性设计)

设计原则

不预判模型能力,而是动态检测响应内容

优点:

  • 无需维护模型列表
  • 自动适配任何新模型
  • 对不支持的模型零开销
  • 始终保持健壮性

动态检测机制

核心思路

在流式响应过程中,实时检测是否出现 thinking 标记:

  • 如果检测到 → 解析并返回 thinking + content
  • 如果未检测到 → 按普通 content 处理

流式处理状态机

enum StreamState {
  WAITING,           // 等待首个 chunk
  IN_THINKING,       // 正在接收 thinking 内容
  IN_CONTENT,        // 正在接收 content 内容
  COMPLETED          // 完成
}

interface StreamProcessor {
  state: StreamState;
  buffer: string;           // 缓冲区,用于检测标签
  thinkingContent: string;  // 累积的 thinking 内容
  contentContent: string;   // 累积的 content 内容
  thinkingDetected: boolean; // 是否检测到 thinking
}

处理流程

async function* processStream(stream: AsyncIterable<{content: string}>) {
  const processor: StreamProcessor = {
    state: StreamState.WAITING,
    buffer: '',
    thinkingContent: '',
    contentContent: '',
    thinkingDetected: false,
  };
  
  for await (const chunk of stream) {
    processor.buffer += chunk.content;
    
    // 状态机处理
    switch (processor.state) {
      case StreamState.WAITING:
        // 检测是否以 thinking 标签开头
        if (processor.buffer.includes('<thinking>')) {
          processor.state = StreamState.IN_THINKING;
          processor.thinkingDetected = true;
          // 提取 <thinking> 之后的内容
          const parts = processor.buffer.split('<thinking>');
          processor.buffer = parts[1] || '';
        } else if (processor.buffer.length > 100) {
          // 超过 100 字符仍未检测到 thinking,判定为普通内容
          processor.state = StreamState.IN_CONTENT;
          yield { type: 'content', data: processor.buffer };
          processor.buffer = '';
        }
        break;
        
      case StreamState.IN_THINKING:
        // 检测 thinking 结束标签
        if (processor.buffer.includes('</thinking>')) {
          const parts = processor.buffer.split('</thinking>');
          processor.thinkingContent += parts[0];
          processor.buffer = parts[1] || '';
          processor.state = StreamState.IN_CONTENT;
          
          // 返回完整的 thinking 内容
          yield { type: 'thinking', data: processor.thinkingContent };
          
          // 如果 buffer 中有内容,作为 content 开始
          if (processor.buffer) {
            yield { type: 'content', data: processor.buffer };
            processor.buffer = '';
          }
        } else {
          // 继续累积 thinking 内容
          // 为了流式显示,可以分批返回
          yield { type: 'thinking', data: chunk.content };
        }
        break;
        
      case StreamState.IN_CONTENT:
        // 直接返回 content
        yield { type: 'content', data: chunk.content };
        break;
    }
  }
  
  // 处理剩余 buffer
  if (processor.buffer) {
    if (processor.state === StreamState.IN_THINKING) {
      yield { type: 'thinking', data: processor.buffer };
    } else {
      yield { type: 'content', data: processor.buffer };
    }
  }
}

三种场景自动适配

场景1:普通模型(不支持 thinking)

检测过程

  1. 收到首个 chunk
  2. 等待 100 字符仍未检测到 <thinking>
  3. 判定为普通内容

返回

  • content 类型
  • thinking 类型

场景2:Reasoning 模型但未触发

检测过程

  1. 收到首个 chunk
  2. 等待 100 字符仍未检测到 <thinking>
  3. 判定为普通内容

返回

  • content 类型
  • thinking 类型

场景3:Reasoning 模型且触发

检测过程

  1. 收到首个 chunk
  2. 检测到 <thinking> 标签
  3. 进入 thinking 状态
  4. 收集直到 </thinking>
  5. 之后的内容作为 content

返回

  • thinking 类型(思考过程)
  • content 类型(正文)

多格式支持

支持多种 thinking 标签格式:

const THINKING_PATTERNS = {
  // 格式1: <thinking>...</thinking>
  xml: {
    start: /<thinking>/i,
    end: /<\/thinking>/i,
  },
  // 格式2: ```thinking\n...\n```
  codeBlock: {
    start: /```thinking/i,
    end: /```/i,
  },
  // 格式3: [THINKING]...[/THINKING]
  bracket: {
    start: /\[THINKING\]/i,
    end: /\[\/THINKING\]/i,
  },
};

// 自动检测使用哪种格式
function detectThinkingFormat(buffer: string): keyof typeof THINKING_PATTERNS | null {
  for (const [format, pattern] of Object.entries(THINKING_PATTERNS)) {
    if (pattern.start.test(buffer)) {
      return format as keyof typeof THINKING_PATTERNS;
    }
  }
  return null;
}

Thinking 内容解析

支持多种 thinking 格式:

function extractThinking(response: string): { thinking: string; content: string } {
  // 格式1: <thinking>...</thinking>
  const thinkingTagMatch = response.match(/<thinking>([\s\S]*?)<\/thinking>/i);
  if (thinkingTagMatch) {
    const thinking = thinkingTagMatch[1].trim();
    const content = response.replace(/<thinking>[\s\S]*?<\/thinking>/i, '').trim();
    return { thinking, content };
  }
  
  // 格式2: ```thinking\n...\n```
  const codeBlockMatch = response.match(/```thinking\n([\s\S]*?)\n```/i);
  if (codeBlockMatch) {
    const thinking = codeBlockMatch[1].trim();
    const content = response.replace(/```thinking\n[\s\S]*?\n```/i, '').trim();
    return { thinking, content };
  }
  
  // 格式3: [THINKING]...[/THINKING]
  const bracketMatch = response.match(/\[THINKING\]([\s\S]*?)\[\/THINKING\]/i);
  if (bracketMatch) {
    const thinking = bracketMatch[1].trim();
    const content = response.replace(/\[THINKING\][\s\S]*?\[\/THINKING\]/i, '').trim();
    return { thinking, content };
  }
  
  // 未检测到 thinking,返回原内容
  return { thinking: '', content: response };
}

前端健壮性处理

前端需要能够处理所有情况:

// 流式响应处理
function handleStreamChunk(chunk: ChatStreamChunk) {
  switch (chunk.type) {
    case 'history':
      setHistory(chunk.data.messages);
      break;
      
    case 'sources':
      setSources(chunk.data);
      break;
      
    case 'thinking':
      // 只有收到 thinking 类型时才显示深度思考区域
      setThinking(prev => prev + chunk.data);
      setShowThinking(true);
      break;
      
    case 'content':
      setContent(prev => prev + chunk.data);
      break;
      
    case 'status':
      setStatus(chunk.data);
      break;
  }
}

// 渲染时的健壮性检查
function renderChat() {
  return (
    <div>
      {/* 上文:只有有内容时才显示 */}
      {history.length > 0 && <HistorySection messages={history} />}
      
      {/* 引用:只有有内容时才显示 */}
      {sources.length > 0 && <SourcesSection sources={sources} />}
      
      {/* 深度思考:只有有内容时才显示 */}
      {showThinking && thinking.length > 0 && (
        <ThinkingSection thinking={thinking} />
      )}
      
      {/* 正文:始终显示 */}
      <ContentSection content={content} />
    </div>
  );
}

配置选项

在模型配置中添加 reasoning 支持选项:

// 模型配置扩展
interface ModelConfig {
  // ... 现有字段
  
  // 新增:reasoning 配置
  reasoning?: {
    enabled: boolean;           // 是否启用 reasoning 解析
    format?: 'auto' | 'thinking_tag' | 'reasoning_field';  // 格式检测方式
  };
}

降级策略总结

模型类型 是否支持 是否触发 返回类型 前端显示
普通模型(如 GPT-4) - content 仅正文
Reasoning模型未触发 content 仅正文
Reasoning模型已触发 thinking + content 深度思考 + 正文

错误处理

// 解析 thinking 时的错误处理
try {
  const { thinking, content } = extractThinking(fullResponse);
  
  if (thinking) {
    yield { type: 'thinking', data: thinking };
  }
  
  yield { type: 'content', data: content };
} catch (error) {
  // 解析失败时,降级为普通 content
  logger.warn('Failed to parse thinking, falling back to content', error);
  yield { type: 'content', data: fullResponse };
}

测试计划

基础功能测试

  1. 测试 history 类型正确返回历史对话
  2. 测试 sources 类型正确返回引用来源
  3. 测试 content 类型正确返回正文
  4. 测试 status 类型正确返回状态信息

深度思考健壮性测试

  1. 测试普通模型:使用 GPT-4 等不支持 reasoning 的模型

    • 验证不返回 thinking 类型
    • 验证只返回 content 类型
    • 验证前端不显示深度思考区域
  2. 测试 Reasoning 模型未触发:使用 DeepSeek-R1 但提问简单问题

    • 验证不返回 thinking 类型
    • 验证只返回 content 类型
  3. 测试 Reasoning 模型已触发:使用 DeepSeek-R1 提问复杂问题

    • 验证返回 thinking 类型
    • 验证返回 content 类型
    • 验证前端正确显示深度思考区域
  4. 测试解析容错:模拟 malformed thinking 标签

    • 验证解析失败时降级为 content
    • 验证不抛出异常

前端渲染测试

  1. 测试前端正确渲染所有类型
  2. 测试前端在没有 thinking 时不显示深度思考区域
  3. 测试前端在有 thinking 时正确显示深度思考区域