feat(LLMService): 重构消息发送逻辑,支持结构化响应与think标签处理
- 更新 `LLMService` 类,新增结构化消息发送方法,返回包含推理内容的响应 - 增强错误处理,确保API调用失败时提供友好的错误提示 - 实现流式处理中的think标签支持,优化推理内容与正文的分离 - 更新相关接口与类型定义,确保兼容性与可读性 - 添加新的单元测试与集成测试,验证结构化响应与think标签处理的正确性 - 更新文档,说明新的API使用方式与最佳实践
This commit is contained in:
1 parent
6f490b36ed
commit
2e9dfd20c7
7 files changed
+773
-50
No files matched your search
+37
-1
@@ -110,4 +110,40 @@ const completionConfig: any = {
|
||||
---
|
||||
|
||||
**最后更新**: 2025年1月
|
||||
**文档状态**: 已简化,移除过时内容,保留核心技术要点
|
||||
**文档状态**: 已简化,移除过时内容,保留核心技术要点
|
||||
|
||||
## 任务:LLM服务结构化响应重构审查 - 2024-01-XX
|
||||
|
||||
### 代码审查建议
|
||||
|
||||
#### 1. Think标签处理器重构建议
|
||||
当前 `processStreamContentWithThinkTags` 方法过于复杂,建议创建独立的 `ThinkTagProcessor` 类:
|
||||
|
||||
```typescript
|
||||
class ThinkTagProcessor {
|
||||
private isInThinkMode = false;
|
||||
private buffer = '';
|
||||
|
||||
processChunk(content: string, callbacks: StreamHandlers): void {
|
||||
// 简化的处理逻辑
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.isInThinkMode = false;
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 错误处理增强
|
||||
- 推理内容解析失败时的降级策略
|
||||
- 不同提供商API差异的统一处理
|
||||
|
||||
#### 3. 测试覆盖建议
|
||||
- 添加错误场景的测试用例
|
||||
- 性能测试:大量think标签的处理性能
|
||||
- 边界条件:恶意构造的think标签
|
||||
|
||||
#### 4. 文档更新需求
|
||||
- API文档需要更新,说明新的结构化响应格式
|
||||
- 使用示例和最佳实践指南
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ILLMService, Message, StreamHandlers, ModelInfo, ModelOption } from './types';
|
||||
import { ILLMService, Message, StreamHandlers, LLMResponse, ModelInfo, ModelOption } from './types';
|
||||
import { ModelConfig } from '../model/types';
|
||||
import { ModelManager, modelManager as defaultModelManager } from '../model/manager';
|
||||
import { APIError, RequestConfigError, ERROR_MESSAGES } from './errors';
|
||||
@@ -130,9 +130,9 @@ export class LLMService implements ILLMService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送OpenAI消息
|
||||
* 发送OpenAI消息(结构化格式)
|
||||
*/
|
||||
private async sendOpenAIMessage(messages: Message[], modelConfig: ModelConfig): Promise<string> {
|
||||
private async sendOpenAIMessageStructured(messages: Message[], modelConfig: ModelConfig): Promise<LLMResponse> {
|
||||
const openai = this.getOpenAIInstance(modelConfig);
|
||||
|
||||
const formattedMessages = messages.map(msg => ({
|
||||
@@ -152,15 +152,54 @@ export class LLMService implements ILLMService {
|
||||
messages: formattedMessages,
|
||||
...restLlmParams // Spread other params from llmParams
|
||||
};
|
||||
const response = await openai.chat.completions.create(completionConfig);
|
||||
|
||||
return response.choices[0].message.content || '';
|
||||
try {
|
||||
const response = await openai.chat.completions.create(completionConfig);
|
||||
|
||||
// 处理响应中的 reasoning_content 和普通 content
|
||||
const choice = response.choices[0];
|
||||
if (!choice?.message) {
|
||||
throw new Error('未收到有效的响应');
|
||||
}
|
||||
|
||||
let content = choice.message.content || '';
|
||||
let reasoning = '';
|
||||
|
||||
// 处理推理内容(如果存在)
|
||||
// SiliconFlow 等提供商在 choice.message 中并列提供 reasoning_content 字段
|
||||
if ((choice.message as any).reasoning_content) {
|
||||
reasoning = (choice.message as any).reasoning_content;
|
||||
} else {
|
||||
// 检测并分离content中的think标签
|
||||
const thinkMatch = content.match(/<think>(.*?)<\/think>/s);
|
||||
if (thinkMatch) {
|
||||
reasoning = thinkMatch[1];
|
||||
content = content.replace(/<think>.*?<\/think>/s, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
const result: LLMResponse = {
|
||||
content: content,
|
||||
reasoning: reasoning || undefined,
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel,
|
||||
finishReason: choice.finish_reason || undefined
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('OpenAI API调用失败:', error);
|
||||
throw new Error(`OpenAI API调用失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 发送Gemini消息
|
||||
* 发送Gemini消息(结构化格式)
|
||||
*/
|
||||
private async sendGeminiMessage(messages: Message[], modelConfig: ModelConfig): Promise<string> {
|
||||
private async sendGeminiMessageStructured(messages: Message[], modelConfig: ModelConfig): Promise<LLMResponse> {
|
||||
// 提取系统消息
|
||||
const systemMessages = messages.filter(msg => msg.role === 'system');
|
||||
const systemInstruction = systemMessages.length > 0
|
||||
@@ -190,16 +229,29 @@ export class LLMService implements ILLMService {
|
||||
? conversationMessages[conversationMessages.length - 1].content
|
||||
: '';
|
||||
|
||||
// 如果没有用户消息,返回空字符串
|
||||
// 如果没有用户消息,返回空响应
|
||||
if (!lastUserMessage) {
|
||||
return '';
|
||||
return {
|
||||
content: '',
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 发送消息并获取响应
|
||||
const result = await chat.sendMessage(lastUserMessage);
|
||||
return result.response.text();
|
||||
|
||||
return {
|
||||
content: result.response.text(),
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 格式化Gemini历史消息
|
||||
*/
|
||||
@@ -231,9 +283,9 @@ export class LLMService implements ILLMService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 发送消息(结构化格式)
|
||||
*/
|
||||
async sendMessage(messages: Message[], provider: string): Promise<string> {
|
||||
async sendMessageStructured(messages: Message[], provider: string): Promise<LLMResponse> {
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new RequestConfigError('模型提供商不能为空');
|
||||
@@ -254,10 +306,10 @@ export class LLMService implements ILLMService {
|
||||
});
|
||||
|
||||
if (modelConfig.provider === 'gemini') {
|
||||
return this.sendGeminiMessage(messages, modelConfig);
|
||||
return this.sendGeminiMessageStructured(messages, modelConfig);
|
||||
} else {
|
||||
// OpenAI兼容格式的API,包括DeepSeek和自定义模型
|
||||
return this.sendOpenAIMessage(messages, modelConfig);
|
||||
return this.sendOpenAIMessageStructured(messages, modelConfig);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof RequestConfigError || error instanceof APIError) {
|
||||
@@ -268,7 +320,18 @@ export class LLMService implements ILLMService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(流式)
|
||||
* 发送消息(传统格式,只返回主要内容)
|
||||
*/
|
||||
async sendMessage(messages: Message[], provider: string): Promise<string> {
|
||||
const response = await this.sendMessageStructured(messages, provider);
|
||||
|
||||
// 只返回主要内容,不包含推理内容
|
||||
// 如果需要推理内容,请使用 sendMessageStructured 方法
|
||||
return response.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(流式,支持结构化和传统格式)
|
||||
*/
|
||||
async sendMessageStream(
|
||||
messages: Message[],
|
||||
@@ -304,6 +367,102 @@ export class LLMService implements ILLMService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理流式内容中的think标签(用于流式场景)
|
||||
*/
|
||||
private processStreamContentWithThinkTags(
|
||||
content: string,
|
||||
callbacks: StreamHandlers,
|
||||
thinkState: { isInThinkMode: boolean; buffer: string }
|
||||
): void {
|
||||
// 如果没有推理回调,直接发送到主要内容流
|
||||
if (!callbacks.onReasoningToken) {
|
||||
callbacks.onToken(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将新内容添加到缓冲区
|
||||
thinkState.buffer += content;
|
||||
let remaining = thinkState.buffer;
|
||||
let processed = '';
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (!thinkState.isInThinkMode) {
|
||||
// 不在think模式中,查找<think>标签
|
||||
const thinkStartIndex = remaining.indexOf('<think>');
|
||||
|
||||
if (thinkStartIndex !== -1) {
|
||||
// 找到了开始标签
|
||||
// 发送开始标签前的内容到主要流
|
||||
if (thinkStartIndex > 0) {
|
||||
const beforeThink = remaining.slice(0, thinkStartIndex);
|
||||
callbacks.onToken(beforeThink);
|
||||
processed += beforeThink + '<think>';
|
||||
} else {
|
||||
processed += '<think>';
|
||||
}
|
||||
|
||||
// 进入think模式
|
||||
thinkState.isInThinkMode = true;
|
||||
remaining = remaining.slice(thinkStartIndex + 7); // 7 = '<think>'.length
|
||||
} else {
|
||||
// 没有找到开始标签
|
||||
// 检查buffer末尾是否可能是不完整的标签开始
|
||||
if (remaining.endsWith('<') || remaining.endsWith('<t') ||
|
||||
remaining.endsWith('<th') || remaining.endsWith('<thi') ||
|
||||
remaining.endsWith('<thin') || remaining.endsWith('<think')) {
|
||||
// 可能是不完整的标签,保留在buffer中等待更多内容
|
||||
thinkState.buffer = remaining;
|
||||
return;
|
||||
} else {
|
||||
// 确定没有标签,发送所有内容到主要流
|
||||
callbacks.onToken(remaining);
|
||||
processed += remaining;
|
||||
remaining = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 在think模式中,查找</think>标签
|
||||
const thinkEndIndex = remaining.indexOf('</think>');
|
||||
|
||||
if (thinkEndIndex !== -1) {
|
||||
// 找到了结束标签
|
||||
// 发送结束标签前的内容到推理流
|
||||
if (thinkEndIndex > 0) {
|
||||
const thinkContent = remaining.slice(0, thinkEndIndex);
|
||||
callbacks.onReasoningToken(thinkContent);
|
||||
}
|
||||
|
||||
// 退出think模式
|
||||
thinkState.isInThinkMode = false;
|
||||
processed += remaining.slice(0, thinkEndIndex) + '</think>';
|
||||
remaining = remaining.slice(thinkEndIndex + 8); // 8 = '</think>'.length
|
||||
} else {
|
||||
// 没有找到结束标签
|
||||
// 检查buffer末尾是否可能是不完整的结束标签
|
||||
if (remaining.endsWith('<') || remaining.endsWith('</') ||
|
||||
remaining.endsWith('</t') || remaining.endsWith('</th') ||
|
||||
remaining.endsWith('</thi') || remaining.endsWith('</thin') ||
|
||||
remaining.endsWith('</think')) {
|
||||
// 可能是不完整的结束标签,保留在buffer中等待更多内容
|
||||
thinkState.buffer = remaining;
|
||||
return;
|
||||
} else {
|
||||
// 确定是think内容,发送到推理流
|
||||
callbacks.onReasoningToken(remaining);
|
||||
processed += remaining;
|
||||
remaining = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓冲区为已处理的内容
|
||||
thinkState.buffer = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送OpenAI消息
|
||||
*/
|
||||
@@ -312,15 +471,15 @@ export class LLMService implements ILLMService {
|
||||
modelConfig: ModelConfig,
|
||||
callbacks: StreamHandlers
|
||||
): Promise<void> {
|
||||
// 获取流式OpenAI实例
|
||||
const openai = this.getOpenAIInstance(modelConfig, true);
|
||||
|
||||
const formattedMessages = messages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
try {
|
||||
// 获取流式OpenAI实例
|
||||
const openai = this.getOpenAIInstance(modelConfig, true);
|
||||
|
||||
const formattedMessages = messages.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
console.log('开始创建流式请求...');
|
||||
const {
|
||||
timeout, // Handled in getOpenAIInstance
|
||||
@@ -343,17 +502,49 @@ export class LLMService implements ILLMService {
|
||||
console.log('成功获取到流式响应');
|
||||
|
||||
// 使用类型断言来确保TypeScript知道这是流式响应
|
||||
let accumulatedReasoning = '';
|
||||
let accumulatedContent = '';
|
||||
|
||||
// think标签状态跟踪
|
||||
const thinkState = { isInThinkMode: false, buffer: '' };
|
||||
|
||||
for await (const chunk of stream as any) {
|
||||
// 处理推理内容(SiliconFlow 等提供商在 delta 中提供 reasoning_content)
|
||||
const reasoningContent = chunk.choices[0]?.delta?.reasoning_content || '';
|
||||
if (reasoningContent) {
|
||||
accumulatedReasoning += reasoningContent;
|
||||
|
||||
// 如果有推理回调,发送推理内容
|
||||
if (callbacks.onReasoningToken) {
|
||||
callbacks.onReasoningToken(reasoningContent);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
// 处理主要内容
|
||||
const content = chunk.choices[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
callbacks.onToken(content);
|
||||
// 添加小延迟,让UI有时间更新
|
||||
accumulatedContent += content;
|
||||
|
||||
// 使用流式think标签处理
|
||||
this.processStreamContentWithThinkTags(content, callbacks, thinkState);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('流式响应完成');
|
||||
callbacks.onComplete();
|
||||
|
||||
// 构建完整响应
|
||||
const response: LLMResponse = {
|
||||
content: accumulatedContent,
|
||||
reasoning: accumulatedReasoning || undefined,
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel
|
||||
}
|
||||
};
|
||||
|
||||
callbacks.onComplete(response);
|
||||
} catch (error) {
|
||||
console.error('流式处理过程中出错:', error);
|
||||
callbacks.onError(error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -398,9 +589,16 @@ export class LLMService implements ILLMService {
|
||||
? conversationMessages[conversationMessages.length - 1].content
|
||||
: '';
|
||||
|
||||
// 如果没有用户消息,返回空字符串
|
||||
// 如果没有用户消息,发送空响应
|
||||
if (!lastUserMessage) {
|
||||
callbacks.onComplete();
|
||||
const response: LLMResponse = {
|
||||
content: '',
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel
|
||||
}
|
||||
};
|
||||
|
||||
callbacks.onComplete(response);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,10 +607,13 @@ export class LLMService implements ILLMService {
|
||||
const result = await chat.sendMessageStream(lastUserMessage);
|
||||
|
||||
console.log('成功获取到流式响应');
|
||||
|
||||
let accumulatedContent = '';
|
||||
|
||||
for await (const chunk of result.stream) {
|
||||
const text = chunk.text();
|
||||
if (text) {
|
||||
accumulatedContent += text;
|
||||
callbacks.onToken(text);
|
||||
// 添加小延迟,让UI有时间更新
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
@@ -420,7 +621,16 @@ export class LLMService implements ILLMService {
|
||||
}
|
||||
|
||||
console.log('流式响应完成');
|
||||
callbacks.onComplete();
|
||||
|
||||
// 构建完整响应
|
||||
const response: LLMResponse = {
|
||||
content: accumulatedContent,
|
||||
metadata: {
|
||||
model: modelConfig.defaultModel
|
||||
}
|
||||
};
|
||||
|
||||
callbacks.onComplete(response);
|
||||
} catch (error) {
|
||||
console.error('流式处理过程中出错:', error);
|
||||
callbacks.onError(error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
@@ -12,9 +12,34 @@ export interface Message {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM响应结构
|
||||
*/
|
||||
export interface LLMResponse {
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
metadata?: {
|
||||
model?: string;
|
||||
tokens?: number;
|
||||
finishReason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式响应处理器
|
||||
* 支持传统格式和结构化格式
|
||||
*/
|
||||
export interface StreamHandlers {
|
||||
// 主要内容流(必需,向后兼容)
|
||||
onToken: (token: string) => void;
|
||||
onComplete: () => void;
|
||||
|
||||
// 推理内容流(可选,新增功能)
|
||||
onReasoningToken?: (token: string) => void;
|
||||
|
||||
// 完成回调(现在传递完整响应,向后兼容通过可选参数)
|
||||
onComplete: (response?: LLMResponse) => void;
|
||||
|
||||
// 错误回调
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
@@ -39,14 +64,24 @@ export interface ModelOption {
|
||||
*/
|
||||
export interface ILLMService {
|
||||
/**
|
||||
* 发送消息
|
||||
* 发送消息(传统格式,返回合并的字符串)
|
||||
* @deprecated 建议使用 sendMessageStructured 获得更好的语义支持
|
||||
* @throws {RequestConfigError} 当参数无效时
|
||||
* @throws {APIError} 当请求失败时
|
||||
*/
|
||||
sendMessage(messages: Message[], provider: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* 发送流式消息
|
||||
* 发送消息(结构化格式)
|
||||
* @throws {RequestConfigError} 当参数无效时
|
||||
* @throws {APIError} 当请求失败时
|
||||
*/
|
||||
sendMessageStructured(messages: Message[], provider: string): Promise<LLMResponse>;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 发送流式消息(支持结构化和传统格式)
|
||||
* @throws {RequestConfigError} 当参数无效时
|
||||
* @throws {APIError} 当请求失败时
|
||||
*/
|
||||
|
||||
@@ -47,8 +47,8 @@ export const defaultModels: Record<string, ModelConfig> = {
|
||||
openai: {
|
||||
name: 'OpenAI',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
models: ['gpt-4', 'gpt-3.5-turbo'],
|
||||
defaultModel: 'gpt-3.5-turbo',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4', 'gpt-3.5-turbo', 'o1', 'o1-mini', 'o1-preview', 'o3', 'o4-mini'],
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
apiKey: OPENAI_API_KEY,
|
||||
enabled: !!OPENAI_API_KEY,
|
||||
provider: 'openai',
|
||||
@@ -69,7 +69,7 @@ export const defaultModels: Record<string, ModelConfig> = {
|
||||
deepseek: {
|
||||
name: 'DeepSeek',
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat'],
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
defaultModel: 'deepseek-chat',
|
||||
apiKey: DEEPSEEK_API_KEY,
|
||||
enabled: !!DEEPSEEK_API_KEY,
|
||||
@@ -80,8 +80,8 @@ export const defaultModels: Record<string, ModelConfig> = {
|
||||
siliconflow: {
|
||||
name: 'SiliconFlow',
|
||||
baseURL: 'https://api.siliconflow.cn/v1',
|
||||
models: ['Pro/deepseek-ai/DeepSeek-V3'],
|
||||
defaultModel: 'Pro/deepseek-ai/DeepSeek-V3',
|
||||
models: ['Qwen/Qwen3-8B'],
|
||||
defaultModel: 'Qwen/Qwen3-8B',
|
||||
apiKey: SILICONFLOW_API_KEY,
|
||||
enabled: !!SILICONFLOW_API_KEY,
|
||||
provider: 'siliconflow',
|
||||
|
||||
@@ -32,6 +32,14 @@ describe('OpenAI API 真实连接测试', () => {
|
||||
|
||||
// 选择第一个可用的密钥和对应的配置
|
||||
const getModelConfig = () => {
|
||||
if (process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY) {
|
||||
return {
|
||||
key: 'siliconflow',
|
||||
apiKey: process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY,
|
||||
baseURL: 'https://api.siliconflow.cn/v1',
|
||||
defaultModel: 'Qwen/Qwen3-8B'
|
||||
};
|
||||
}
|
||||
if (process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY) {
|
||||
return {
|
||||
key: 'openai',
|
||||
@@ -48,14 +56,6 @@ describe('OpenAI API 真实连接测试', () => {
|
||||
defaultModel: 'deepseek-chat'
|
||||
};
|
||||
}
|
||||
if (process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY) {
|
||||
return {
|
||||
key: 'siliconflow',
|
||||
apiKey: process.env.SILICONFLOW_API_KEY || process.env.VITE_SILICONFLOW_API_KEY,
|
||||
baseURL: 'https://api.siliconflow.cn/v1',
|
||||
defaultModel: 'Pro/deepseek-ai/DeepSeek-V3'
|
||||
};
|
||||
}
|
||||
if (process.env.ZHIPU_API_KEY || process.env.VITE_ZHIPU_API_KEY) {
|
||||
return {
|
||||
key: 'zhipu',
|
||||
@@ -190,7 +190,7 @@ describe('OpenAI API 真实连接测试', () => {
|
||||
expect(typeof response).toBe('string');
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
// 由于设置了max_tokens=100,响应应该相对较短
|
||||
expect(response.length).toBeLessThan(500);
|
||||
expect(response.length).toBeLessThan(200);
|
||||
} catch (error) {
|
||||
console.error(`高级参数测试失败 (${modelConfig.key}):`, error.message);
|
||||
if (error.message.includes('400')) {
|
||||
@@ -200,4 +200,310 @@ describe('OpenAI API 真实连接测试', () => {
|
||||
throw error;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('应该能兼容处理所有模型的响应格式(reasoning_content + think标签 + 普通文本)', async () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
|
||||
try {
|
||||
// 测试通用兼容性处理
|
||||
await modelManager.updateModel(modelConfig.key, {
|
||||
apiKey: modelConfig.apiKey,
|
||||
baseURL: modelConfig.baseURL,
|
||||
defaultModel: modelConfig.defaultModel,
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
llmParams: {
|
||||
temperature: 0.1,
|
||||
max_tokens: 100
|
||||
}
|
||||
});
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请简单回答:什么是AI?'
|
||||
}
|
||||
];
|
||||
|
||||
// 测试非流式处理
|
||||
const result = await llmService.sendMessage(testMessages, modelConfig.key);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
console.log('兼容性测试结果:', {
|
||||
hasThinkTags: result.includes('<think>'),
|
||||
hasContent: result.length > 0,
|
||||
result: result
|
||||
});
|
||||
|
||||
// 测试流式处理
|
||||
let streamResult = '';
|
||||
let tokenCount = 0;
|
||||
let isCompleted = false;
|
||||
let hasError = false;
|
||||
|
||||
await llmService.sendMessageStream(testMessages, modelConfig.key, {
|
||||
onToken: (token) => {
|
||||
streamResult += token;
|
||||
tokenCount++;
|
||||
},
|
||||
onComplete: (response) => {
|
||||
isCompleted = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
hasError = true;
|
||||
console.error('流式测试错误:', error);
|
||||
}
|
||||
});
|
||||
|
||||
expect(hasError).toBe(false);
|
||||
expect(isCompleted).toBe(true);
|
||||
expect(streamResult.length).toBeGreaterThan(0);
|
||||
expect(tokenCount).toBeGreaterThan(0);
|
||||
|
||||
console.log('流式兼容性测试结果:', {
|
||||
tokenCount,
|
||||
hasThinkTags: streamResult.includes('<think>'),
|
||||
streamLength: streamResult.length,
|
||||
isCompleted
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('兼容性测试失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},60000);
|
||||
|
||||
it('应该能正确处理reasoning_content的流式输出', async () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
|
||||
try {
|
||||
// 配置模型
|
||||
await modelManager.updateModel(modelConfig.key, {
|
||||
apiKey: modelConfig.apiKey,
|
||||
baseURL: modelConfig.baseURL,
|
||||
defaultModel: modelConfig.defaultModel,
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
llmParams: {
|
||||
temperature: 0.1,
|
||||
max_tokens: 2000
|
||||
}
|
||||
});
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '你是谁'
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟包含reasoning_content的流式响应
|
||||
let fullResult = '';
|
||||
let tokenCount = 0;
|
||||
let hasThinkTags = false;
|
||||
let thinkTagsClosed = false;
|
||||
let isCompleted = false;
|
||||
let hasError = false;
|
||||
|
||||
await llmService.sendMessageStream(testMessages, modelConfig.key, {
|
||||
onToken: (token) => {
|
||||
fullResult += token;
|
||||
tokenCount++;
|
||||
|
||||
// 检查think标签的完整性
|
||||
if (token.includes('<think>')) {
|
||||
hasThinkTags = true;
|
||||
}
|
||||
if (token.includes('</think>')) {
|
||||
thinkTagsClosed = true;
|
||||
}
|
||||
},
|
||||
onComplete: (response) => {
|
||||
isCompleted = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
hasError = true;
|
||||
console.error('流式测试错误:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待流式完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('reasoning_content流式测试结果:', {
|
||||
tokenCount,
|
||||
hasThinkTags,
|
||||
thinkTagsClosed,
|
||||
isCompleted,
|
||||
hasError,
|
||||
resultLength: fullResult.length,
|
||||
fullResult: fullResult
|
||||
});
|
||||
|
||||
expect(isCompleted).toBe(true);
|
||||
expect(hasError).toBe(false);
|
||||
expect(tokenCount).toBeGreaterThan(0);
|
||||
expect(fullResult.length).toBeGreaterThan(0);
|
||||
|
||||
// 如果有think标签,检查它们是否正确闭合
|
||||
const thinkOpenCount = (fullResult.match(/<think>/g) || []).length;
|
||||
const thinkCloseCount = (fullResult.match(/<\/think>/g) || []).length;
|
||||
|
||||
if (thinkOpenCount > 0) {
|
||||
expect(thinkOpenCount).toBe(thinkCloseCount);
|
||||
console.log(`✅ Think标签匹配: ${thinkOpenCount} 个开始标签, ${thinkCloseCount} 个结束标签`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('reasoning_content流式测试失败:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
it('应该能使用结构化API发送消息', async () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
|
||||
try {
|
||||
// 配置模型
|
||||
await modelManager.updateModel(modelConfig.key, {
|
||||
apiKey: modelConfig.apiKey,
|
||||
baseURL: modelConfig.baseURL,
|
||||
defaultModel: modelConfig.defaultModel,
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
llmParams: {
|
||||
temperature: 0.3,
|
||||
max_tokens: 100
|
||||
}
|
||||
});
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请简单回答:什么是AI?'
|
||||
}
|
||||
];
|
||||
|
||||
// 测试结构化API
|
||||
const response = await llmService.sendMessageStructured(testMessages, modelConfig.key);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(typeof response).toBe('object');
|
||||
expect(response.content).toBeDefined();
|
||||
expect(typeof response.content).toBe('string');
|
||||
expect(response.content.length).toBeGreaterThan(0);
|
||||
|
||||
// 检查元数据
|
||||
expect(response.metadata).toBeDefined();
|
||||
expect(response.metadata.model).toBe(modelConfig.defaultModel);
|
||||
|
||||
console.log('结构化API测试结果:', {
|
||||
hasContent: response.content.length > 0,
|
||||
hasReasoning: !!response.reasoning,
|
||||
content: response.content,
|
||||
reasoning: response.reasoning,
|
||||
model: response.metadata?.model
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('结构化API测试失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('应该能使用结构化回调进行流式处理', async () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
|
||||
try {
|
||||
// 配置模型
|
||||
await modelManager.updateModel(modelConfig.key, {
|
||||
apiKey: modelConfig.apiKey,
|
||||
baseURL: modelConfig.baseURL,
|
||||
defaultModel: modelConfig.defaultModel,
|
||||
enabled: true,
|
||||
provider: 'openai',
|
||||
llmParams: {
|
||||
temperature: 0.1,
|
||||
max_tokens: 1500
|
||||
}
|
||||
});
|
||||
|
||||
const testMessages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请简单回答:什么是AI?'
|
||||
}
|
||||
];
|
||||
|
||||
let contentTokens = '';
|
||||
let reasoningTokens = '';
|
||||
let finalResponse = null;
|
||||
let contentTokenCount = 0;
|
||||
let reasoningTokenCount = 0;
|
||||
let isCompleted = false;
|
||||
let hasError = false;
|
||||
|
||||
await llmService.sendMessageStream(testMessages, modelConfig.key, {
|
||||
onToken: (token) => {
|
||||
contentTokens += token;
|
||||
contentTokenCount++;
|
||||
},
|
||||
onReasoningToken: (token) => {
|
||||
reasoningTokens += token;
|
||||
reasoningTokenCount++;
|
||||
},
|
||||
onComplete: (response) => {
|
||||
finalResponse = response;
|
||||
isCompleted = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
hasError = true;
|
||||
console.error('结构化流式测试错误:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待流式完成
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('结构化流式测试结果:', {
|
||||
contentTokenCount,
|
||||
reasoningTokenCount,
|
||||
isCompleted,
|
||||
hasError,
|
||||
content: contentTokens,
|
||||
reasoning: reasoningTokens,
|
||||
finalResponse: finalResponse
|
||||
});
|
||||
|
||||
expect(isCompleted).toBe(true);
|
||||
expect(hasError).toBe(false);
|
||||
expect(finalResponse).toBeDefined();
|
||||
expect(finalResponse.content).toBeDefined();
|
||||
expect(contentTokenCount).toBeGreaterThan(0);
|
||||
expect(contentTokens.length).toBeGreaterThan(0);
|
||||
|
||||
// 验证内容一致性
|
||||
expect(contentTokens).toBe(finalResponse.content);
|
||||
|
||||
// 如果有推理内容,验证一致性
|
||||
if (reasoningTokenCount > 0) {
|
||||
expect(reasoningTokens).toBe(finalResponse.reasoning || '');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('结构化流式测试失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -476,6 +476,8 @@ describe('LLM Parameters (llmParams) Functionality', () => {
|
||||
describe('Gemini Specific Parameters', () => {
|
||||
if (hasGeminiKey && geminiConfig) {
|
||||
it('should accept valid maxOutputTokens for Gemini provider', async () => {
|
||||
// 添加间隔,避免频率限制,先等10秒
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
@@ -499,9 +501,10 @@ describe('LLM Parameters (llmParams) Functionality', () => {
|
||||
expect(response).toBeDefined();
|
||||
expect(typeof response).toBe('string');
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
|
||||
}, 60000);
|
||||
it('should accept valid candidateCount for Gemini provider', async () => {
|
||||
// 添加间隔,避免频率限制,先等10秒
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = createLLMService(modelManager);
|
||||
@@ -525,7 +528,7 @@ describe('LLM Parameters (llmParams) Functionality', () => {
|
||||
expect(response).toBeDefined();
|
||||
expect(typeof response).toBe('string');
|
||||
expect(response.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LLMService } from '../../../src/services/llm/service';
|
||||
import { ModelManager } from '../../../src/services/model/manager';
|
||||
import { LocalStorageProvider } from '../../../src/services/storage/localStorageProvider';
|
||||
|
||||
describe('Think标签处理测试', () => {
|
||||
|
||||
describe('流式处理', () => {
|
||||
it('应该能正确处理流式think标签:开始标签 -> 推理内容 -> 结束标签 -> 正文', () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = new LLMService(modelManager);
|
||||
|
||||
const mockCallbacks = {
|
||||
onToken: vi.fn(),
|
||||
onReasoningToken: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn()
|
||||
};
|
||||
|
||||
const thinkState = { isInThinkMode: false, buffer: '' };
|
||||
|
||||
// 模拟流式处理:分多个chunk接收
|
||||
// Chunk 1: 开始标签
|
||||
(llmService as any).processStreamContentWithThinkTags('<think>', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 2: 推理内容第一部分
|
||||
(llmService as any).processStreamContentWithThinkTags('我需要思考', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 3: 推理内容第二部分
|
||||
(llmService as any).processStreamContentWithThinkTags('这个问题', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 4: 结束标签
|
||||
(llmService as any).processStreamContentWithThinkTags('</think>', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 5: 正文内容
|
||||
(llmService as any).processStreamContentWithThinkTags('这是最终答案', mockCallbacks, thinkState);
|
||||
|
||||
// 验证推理内容被正确分离
|
||||
expect(mockCallbacks.onReasoningToken).toHaveBeenCalledWith('我需要思考');
|
||||
expect(mockCallbacks.onReasoningToken).toHaveBeenCalledWith('这个问题');
|
||||
|
||||
// 验证正文内容被正确发送
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('这是最终答案');
|
||||
|
||||
// 验证没有将标签内容发送到主流
|
||||
expect(mockCallbacks.onToken).not.toHaveBeenCalledWith('<think>');
|
||||
expect(mockCallbacks.onToken).not.toHaveBeenCalledWith('</think>');
|
||||
});
|
||||
|
||||
it('应该能处理单个chunk包含完整think标签的情况', () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = new LLMService(modelManager);
|
||||
|
||||
const mockCallbacks = {
|
||||
onToken: vi.fn(),
|
||||
onReasoningToken: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn()
|
||||
};
|
||||
|
||||
const thinkState = { isInThinkMode: false, buffer: '' };
|
||||
|
||||
// 单个chunk包含完整的think标签
|
||||
(llmService as any).processStreamContentWithThinkTags(
|
||||
'前面内容<think>推理过程</think>后面内容',
|
||||
mockCallbacks,
|
||||
thinkState
|
||||
);
|
||||
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('前面内容');
|
||||
expect(mockCallbacks.onReasoningToken).toHaveBeenCalledWith('推理过程');
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('后面内容');
|
||||
});
|
||||
|
||||
it('应该能处理跨chunk的think标签', () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = new LLMService(modelManager);
|
||||
|
||||
const mockCallbacks = {
|
||||
onToken: vi.fn(),
|
||||
onReasoningToken: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn()
|
||||
};
|
||||
|
||||
const thinkState = { isInThinkMode: false, buffer: '' };
|
||||
|
||||
// Chunk 1: 包含开始标签的一部分
|
||||
(llmService as any).processStreamContentWithThinkTags('前面<thi', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 2: 完成开始标签并开始推理内容
|
||||
(llmService as any).processStreamContentWithThinkTags('nk>推理开始', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 3: 推理内容和部分结束标签
|
||||
(llmService as any).processStreamContentWithThinkTags('推理结束</thi', mockCallbacks, thinkState);
|
||||
|
||||
// Chunk 4: 完成结束标签并开始正文
|
||||
(llmService as any).processStreamContentWithThinkTags('nk>正文内容', mockCallbacks, thinkState);
|
||||
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('前面');
|
||||
expect(mockCallbacks.onReasoningToken).toHaveBeenCalledWith('推理开始');
|
||||
expect(mockCallbacks.onReasoningToken).toHaveBeenCalledWith('推理结束');
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('正文内容');
|
||||
});
|
||||
|
||||
it('应该能处理没有推理回调的流式情况', () => {
|
||||
const storage = new LocalStorageProvider();
|
||||
const modelManager = new ModelManager(storage);
|
||||
const llmService = new LLMService(modelManager);
|
||||
|
||||
const mockCallbacks = {
|
||||
onToken: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
onError: vi.fn()
|
||||
// 注意:没有 onReasoningToken
|
||||
};
|
||||
|
||||
const thinkState = { isInThinkMode: false, buffer: '' };
|
||||
|
||||
(llmService as any).processStreamContentWithThinkTags(
|
||||
'<think>推理过程</think>正文内容',
|
||||
mockCallbacks,
|
||||
thinkState
|
||||
);
|
||||
|
||||
// 当没有推理回调时,应该直接发送原始内容
|
||||
expect(mockCallbacks.onToken).toHaveBeenCalledWith('<think>推理过程</think>正文内容');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user