<think>我们有一个使用Coze的Python代码,现在希望将其重构为使用Ollama本地服务(无需联网)。
原代码主要包含一个`CozeClient`类,它负责与Coze API交互,包括获取访问令牌、流式对话等。
我们需要将这部分替换为使用Ollama的本地API。
分析原代码功能:
1. 初始化客户端(包含应用ID、私钥等)
2. 管理对话历史(`ConversationHistory`类)
3. 流式对话(`stream_chat`方法): 发送消息并接收流式响应,通过回调函数处理每个事件(如消息增量、完成等)
重构计划:
1. 移除与Coze认证相关的部分(如JWT令牌、access token等)。
2. 保留对话历史管理(`ConversationHistory`类),因为Ollama本身不维护对话历史,需要我们自己维护。
3. 重新实现`stream_chat`方法,使其调用Ollama的流式API(`/api/generate`)[^1]。
4. 调整回调函数的事件类型,因为Ollama的响应格式与Coze不同。
注意:Ollama的流式API返回的是JSON字符串序列,每个JSON对象包含响应的一部分(`"response"`字段)以及是否完成(`"done"`字段)等信息。
重构后的客户端类可以命名为`OllamaClient`,它将替代原来的`CozeClient`。
步骤:
1. 创建`OllamaClient`类,初始化时指定Ollama的服务地址(默认为`http://localhost:11434`)和模型名称。
2. 保留对话历史管理(`conversation_histories`和`user_conversations`)以及会话保存功能。
3. 重构`stream_chat`方法:
- 构建请求数据:包括模型名称、提示(prompt)、上下文(context,即历史对话的向量?但实际上Ollama的API需要完整的消息历史,我们可以将历史消息和当前消息组合成完整的对话)
- 注意:Ollama的API要求将整个对话历史作为`messages`数组发送(类似于OpenAI的格式),每个消息包含`role`和`content`。
- 流式请求:使用`requests`库流式读取响应,并解析每个JSON块。
- 模拟原代码中的事件回调:
原Coze事件:conversation.message.delta, conversation.message, error, done
Ollama中,我们可以将每个收到的非空响应块视为一个增量(类似于delta),当收到最后一块(`done`为true)时触发done事件。
4. 在回调中,我们可能需要模拟相同的事件结构,但为了简化,我们可以只使用两种事件:
- "message_delta": 当收到一个响应块时,传递该块的内容。
- "done": 当整个响应完成时触发。
5. 注意:Ollama的响应中不维护会话ID,所以我们不需要处理会话ID(原代码中的conversation_id)。我们仍然使用原来的用户ID来管理对话历史。
6. 在`llm_response`函数中,将原来使用`CozeClient`的地方改为使用`OllamaClient`。
实现细节:
1. `OllamaClient`的构造函数:
def __init__(self, model: str, base_url: str = "http://localhost:11434"):
self.model = model
self.base_url = base_url
# 其他成员(对话历史等)与原来CozeClient相同
2. 移除所有与Coze认证相关的方法(如`_create_jwt_token`, `_get_access_token`, `_rsa_sign`等)。
3. `stream_chat`方法实现:
- 构建请求URL: `f"{self.base_url}/api/chat"`
- 构建请求体(JSON):
{
"model": self.model,
"messages": [所有历史消息 + 当前消息], # 注意:Ollama的API需要完整的消息列表,每条消息包含role和content
"stream": True
}
- 注意:Ollama的API要求消息列表的格式,我们之前的历史消息和当前消息已经是一个列表,可以直接使用。
4. 处理流式响应:
- 遍历响应行(response.iter_lines())
- 每一行是一个JSON对象,解析它
- 如果该JSON对象中有`"message"`字段(但实际上Ollama的流式聊天API返回的是`/api/chat`,其响应结构为:每个块是一个对象,包含`message`(当为流式时,这个字段在非最后一块实际上不存在?)或`content`?我们需要查阅Ollama的API文档。
查阅Ollama的API文档(根据引用[1]的提示,我们使用的是`/api/chat`):
请求方法:POST
请求体:
{
"model": "model名称",
"messages": [
{"role": "user", "content": "你好"}
],
"stream": true
}
流式响应示例(每个块):
{
"model": "llama3.2:3b",
"created_at": "2024-07-05T10:08:49.466Z",
"message": {
"role": "assistant",
"content": "嗨"
},
"done": false
}
... 多个块 ...
{
"model": "llama3.2:3b",
"created_at": "2024-07-05T10:08:49.466Z",
"message": {
"role": "assistant",
"content": "!"
},
"done": true
}
但是注意:在流式模式下,每个块只包含此次响应的增量(即content字段是当前块的内容,而不是整个消息)。但是,观察上面的例子,每个块中都有一个完整的`message`对象,其中`content`是到当前块为止的完整回复?这显然不合理。
实际上,根据Ollama文档(https://github.com/ollama/ollama/blob/main/docs/api.md):
流式聊天API(/api/chat)的响应对象中,有一个`message`字段,但它是可选的,并且在流式模式下,每个块只包含一个`content`字段(增量)?或者包含整个消息?
根据官方文档示例(流式):
```
{
"model": "llama3.2:3b",
"created_at": "2024-07-05T10:08:49.466Z",
"message": {
"role": "assistant",
"content": "嗨"
},
"done": false
}
{
"model": "llama3.2:3b",
"created_at": "2024-07-05T10:08:49.466Z",
"message": {
"role": "assistant",
"content": "!"
},
"done": true
}
```
实际上,每个块中的`content`是当前块的内容,但是每个块都是独立的消息对象。这意味着,如果我们想要获取完整的回复,需要将所有块的`content`拼接起来。
然而,这样设计会导致在流式传输时,每个块都覆盖了之前的内容?显然不是,因为每个块都是增量。
但是,官方文档中描述,在流式响应中,响应对象中有一个`content`字段(不在`message`内?)?我查阅了文档,文档中说明:
响应对象(流式):
{
"model": "string",
"created_at": "string",
"message": {
"role": "assistant",
"content": "string", // 注意:这个content是到当前块为止的完整内容?还是只是增量?
},
"done": false
}
实际上,文档并没有明确说明,但示例中每个块的content都是整个消息到目前为止的内容(即增量更新)?还是只发送增量?
根据官方文档的示例代码(JavaScript)处理流式响应的方式:
for await (const part of stream) {
// 打印每个块的内容(增量)
process.stdout.write(part.message.content);
}
所以,每个块的`message.content`是该块的新内容(增量),而不是整个消息。
因此,我们可以这样处理:
当收到一个块时,该块中的`message.content`就是本次的增量文本(可能只有一个词或几个字)。
所以,在回调中,我们可以触发一个自定义事件(比如"delta"),传递增量内容。
但是,为了与原代码中的事件类型兼容,我们可以模拟:
event: "delta" -> 对应原代码的"conversation.message.delta"
数据格式:我们可以构造一个字典,包含'content'字段,值为增量文本。
另外,当`done`为true时,触发"done"事件。
注意:Ollama的响应中没有会话ID(conversation_id)的概念,所以我们可以忽略。
另外,Ollama的响应中也没有原代码中的`reasoning_content`,所以我们可以不处理。
5. 在`stream_chat`方法中,我们发送请求并处理流式响应:
- 构建请求数据:将历史消息(由`ConversationHistory`管理的消息)和当前的消息合并成一个列表,作为`messages`参数。
- 设置`stream=True`。
请求示例:
data = {
"model": self.model,
"messages": all_messages, # 包含历史消息和当前用户消息
"stream": True
}
- 发送POST请求到`/api/chat`。
- 处理响应:按行读取(注意Ollama的响应是多个JSON对象,每行一个?)
注意:响应是多个JSON对象,每个对象占一行(NDJSON格式)。
6. 在回调中,我们这样处理:
- 解析每一行的JSON(去掉前面的'data:',如果有的话?但根据Ollama文档,它直接返回JSON对象,没有前缀)
- 检查该对象是否有`message`字段,并且`message`字段中有`content`(即使为空字符串也可能有)
- 触发事件:"delta",数据格式示例:{'content': 当前块的文本}
- 如果`done`为true,则触发"done"事件。
7. 保存完整回复:在每次收到增量时,将其追加到一个缓冲区,当收到"done"事件时,将整个回复加入历史记录(作为assistant的一条消息)。
8. 注意:用户的消息在调用`stream_chat`之前已经添加到历史记录中(原代码在`llm_response`中添加了用户消息)。因此,在构建Ollama的`messages`时,我们已经包含了最新的用户消息。
9. 错误处理:如果在请求过程中发生错误(如网络错误、Ollama返回错误等),触发"error"事件。
重构后的`OllamaClient.stream_chat`方法示例代码:
def stream_chat(
self,
conversation_id: Optional[str], # 为了兼容保留,但Ollama不需要
user_id: str,
messages: List[Dict], # 注意:这个messages是当前轮的用户消息(可能包含多条?但通常只有一条用户消息)
on_message: Callable[[str, Dict], None],
cancellation_token=None,
max_history_rounds: int = 5,
nerfreal: Optional[BaseReal] = None,
):
# 超时检查(保留原逻辑)
if (conversation_id and
time.time() - self.last_activity.get(user_id, 0) > self.CONVERSATION_TIMEOUT):
self.clear_conversation_history(user_id)
conversation_id = None
logger.info(f"Conversation timeout, new session started")
# 获取历史记录(包含之前的对话)并加上当前轮的消息(messages)
history = self.get_conversation_history(user_id, max_history_rounds)
all_messages = history + messages # 注意:这里顺序是先历史再当前消息
# 构建请求URL
url = f"{self.base_url}/api/chat"
# 请求头
headers = {
"Content-Type": "application/json"
}
# 请求数据
data = {
"model": self.model,
"messages": all_messages,
"stream": True
}
# 回调包装器:用于收集完整回复并处理历史记录
full_response = ""
buffer = "" # 原代码有按句子分割的逻辑,我们保留
def wrapped_callback(event: str, msg: dict):
nonlocal full_response, buffer
# 原代码中的事件处理逻辑,我们调整为处理Ollama的事件
# 这里我们简化,只处理'delta'和'done'
# 在Ollama中,我们将每个非done的块都视为delta,然后done事件时保存历史
if event == "delta":
content = msg.get('content', '')
# 原代码中按句子分割并输出到nerfreal的逻辑
if content:
buffer += content
sentence_enders = ['.', '!', '?', '。', '!', '?']
# 检查是否有句子结束符
while any(ender in buffer for ender in sentence_enders):
end_index = next((i for i, char in enumerate(buffer) if char in sentence_enders), None)
if end_index is not None:
sentence = buffer[:end_index+1]
# 过滤URLs (原代码有_filter_urls方法)
filtered_sentence = self._filter_urls(sentence)
if nerfreal:
nerfreal.put_msg_txt(filtered_sentence)
if os.path.exists('systemReplyArray.txt'):
with open('systemReplyArray.txt', 'r+', encoding='utf-8') as f:
previous_lines = f.read().splitlines()[:3]
f.seek(0)
f.write(sentence + '\n' + '\n'.join(previous_lines))
f.truncate()
logger.info(f"Processed sentence: {sentence}")
full_response += sentence
buffer = buffer[end_index+1:]
elif event == "done":
if buffer:
filtered_buffer = self._filter_urls(buffer)
if nerfreal:
nerfreal.put_msg_txt(filtered_buffer)
logger.info(f"Final buffer content: {buffer}")
full_response += buffer
buffer = ""
# 将完整回复加入历史记录(作为assistant的一条消息)
if user_id in self.conversation_histories:
self.conversation_histories[user_id].add_message("assistant", full_response)
# 写入完整回复到文件
with open('systemReply.txt', 'w', encoding='utf-8') as f:
f.write(full_response)
# 调用原始回调
on_message(event, msg)
try:
response = requests.post(url, json=data, headers=headers, stream=True, timeout=300)
if response.status_code != 200:
wrapped_callback("error", {"error": f"HTTP error: {response.status_code}", "details": response.text})
return
# 处理流式响应
for line in response.iter_lines():
if cancellation_token and cancellation_token.is_cancelled():
wrapped_callback("user_cancel", None)
break
if line:
# 解码
try:
chunk = json.loads(line)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON: {line}")
continue
# 检查是否出错
if "error" in chunk:
wrapped_callback("error", {"error": chunk["error"]})
break
# 获取增量内容
if "message" in chunk and "content" in chunk["message"]:
content = chunk["message"]["content"]
# 触发delta事件
wrapped_callback("delta", {'content': content})
# 检查是否完成
if chunk.get("done", False):
wrapped_callback("done", {})
break
except Exception as e:
wrapped_callback("error", {"error": str(e)})
10. 注意:原代码中有一个`conversation_id`,在Ollama中我们不需要,所以重构后我们忽略这个参数(但为了接口兼容保留)。
11. 在`llm_response`函数中,我们需要将`CozeClient`替换为`OllamaClient`,并传入模型名称(如"llama3.2:3b")和Ollama服务地址(如果默认在本地则不需要改)。
12. 初始化全局实例:
原代码:
_coze_client_instance = CozeClient(...)
改为:
_ollama_client_instance = OllamaClient(model="llama3.2:3b", base_url="http://localhost:11434")
13. 注意:原代码中CozeClient的构造参数(app_id, private_key, kid, bot_id)不再需要。
14. 保留对话历史管理功能,因为这是通用的。
15. 移除不需要的方法(例如`_create_jwt_token`等)。
由于代码较长,下面给出重构后的关键部分代码:
</think>我将把您的Coze代码重构为使用Ollama本地服务的版本。以下是完整的重构代码实现:
```python
import time
import os
import requests
import json
from logger import logger
from typing import Dict, List, Optional, Callable, Any
import threading
from basereal import BaseReal
class ConversationHistory:
"""对话历史管理类"""
def __init__(self, max_rounds=5):
self.max_rounds = max_rounds
self.history = []
def add_message(self, role: str, content: str):
"""添加消息到历史记录"""
self.history.append({"role": role, "content": content})
# 保持最多max_rounds轮对话
self.history = self.history[-(self.max_rounds*2):]
def get_messages(self) -> List[Dict]:
"""获取完整的历史消息列表"""
return self.history.copy()
def clear(self):
"""清空历史记录"""
self.history = []
class OllamaClient:
"""Ollama本地服务客户端"""
def __init__(self, model_name: str = "llama3.2:3b", base_url: str = "http://localhost:11434"):
"""
初始化Ollama客户端
:param model_name: 模型名称
:param base_url: Ollama服务地址
"""
self.model = model_name
self.base_url = base_url
self.last_activity = {}
self.conversation_histories = {} # 用户对话历史存储
self.user_conversations = {} # {user_id: conversation_id}
self.session_file = "ollama_sessions.json"
self._load_sessions() # 初始化时加载保存的会话
self.CONVERSATION_TIMEOUT = 1800 # 30分钟超时
def _load_sessions(self):
try:
with open(self.session_file, 'r') as f:
data = json.load(f)
self.user_conversations = data.get("user_conversations", {})
self.conversation_histories = {
uid: ConversationHistory(max_rounds=5)
for uid in data.get("conversation_histories", {})
}
except (FileNotFoundError, json.JSONDecodeError):
self.user_conversations = {}
self.conversation_histories = {}
def _save_sessions(self):
data = {
"user_conversations": self.user_conversations,
"conversation_histories": {
uid: hist.get_messages()
for uid, hist in self.conversation_histories.items()
}
}
with open(self.session_file, 'w') as f:
json.dump(data, f)
def get_conversation_id(self, user_id: str) -> Optional[str]:
conv_id = self.user_conversations.get(user_id)
if conv_id:
self.last_activity[user_id] = time.time() # 更新活动时间
return conv_id
def get_conversation_history(self, user_id: str, max_rounds: int = 5) -> List[Dict]:
"""获取用户对话历史"""
if user_id not in self.conversation_histories:
self.conversation_histories[user_id] = ConversationHistory(max_rounds)
return self.conversation_histories[user_id].get_messages()
def clear_conversation_history(self, user_id: str):
"""清空用户对话历史"""
if user_id in self.conversation_histories:
self.conversation_histories[user_id].clear()
def stream_chat(
self,
conversation_id: Optional[str],
user_id: str,
messages: List[Dict],
on_message: Callable[[str, Dict], None],
cancellation_token=None,
max_history_rounds: int = 5,
nerfreal: Optional[BaseReal] = None,
):
"""
Ollama流式对话
:param max_history_rounds: 最大历史对话轮数
:param nerfreal: BaseReal实例,用于消息输出
"""
# 超时检查
if (conversation_id and
time.time() - self.last_activity.get(user_id, 0) > self.CONVERSATION_TIMEOUT):
self.clear_conversation_history(user_id)
conversation_id = None
logger.info(f"对话超时,已启动新会话")
logger.info(f"开始聊天 - 用户: {user_id}, 现有会话ID: {conversation_id}")
# 获取历史记录并合并新消息
history = self.get_conversation_history(user_id, max_history_rounds)
all_messages = history + messages
# 构造Ollama请求
url = f"{self.base_url}/api/chat"
data = {
"model": self.model,
"messages": all_messages,
"stream": True
}
headers = {"Content-Type": "application/json"}
# 定义包装回调以处理历史记录
full_response = ""
buffer = ""
def wrapped_callback(event: str, msg: dict):
nonlocal full_response, buffer
logger.debug(f"收到事件: {event}, 数据: {json.dumps(msg, ensure_ascii=False)}")
if event == "delta":
content = msg.get("content", "")
if content:
buffer += content
sentence_enders = ['.', '!', '?', '。', '!', '?']
while any(ender in buffer for ender in sentence_enders):
end_index = next((i for i, char in enumerate(buffer) if char in sentence_enders), None)
if end_index is not None:
sentence = buffer[:end_index + 1]
if nerfreal:
nerfreal.put_msg_txt(self._filter_urls(sentence))
# 优化历史记录处理
if os.path.exists('systemReplyArray.txt'):
with open('systemReplyArray.txt', 'r+', encoding='utf-8') as f:
previous_lines = f.read().splitlines()[:3]
f.seek(0)
f.write(sentence + '\n' + '\n'.join(previous_lines))
f.truncate()
logger.info(f"处理句子: {sentence}")
full_response += sentence
buffer = buffer[end_index + 1:]
elif event == "done":
if buffer:
if nerfreal:
nerfreal.put_msg_txt(self._filter_urls(buffer))
logger.info(f"最终缓冲区内容: {buffer}")
full_response += buffer
buffer = ""
# 记录完整对话历史
logger.info(f"完整回复: {full_response}")
# 将完整回复加入历史记录
if user_id in self.conversation_histories:
self.conversation_histories[user_id].add_message("assistant", full_response)
# 写入完整回复到文件
with open('systemReply.txt', 'w', encoding='utf-8') as f:
f.write(full_response)
# 调用原始回调
on_message(event, msg)
try:
response = requests.post(url, json=data, headers=headers, stream=True, timeout=300)
if response.status_code != 200:
wrapped_callback("error", {"error": response.text})
return
for line in response.iter_lines():
if cancellation_token and cancellation_token.is_cancelled():
wrapped_callback("user_cancel", None)
break
if not line:
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
# 处理Ollama响应
if chunk.get("done", False):
wrapped_callback("done", {})
break
if "message" in chunk and "content" in chunk["message"]:
content = chunk["message"]["content"]
wrapped_callback("delta", {"content": content})
except Exception as e:
wrapped_callback("error", {"error": str(e)})
@staticmethod
def _filter_urls(text: str) -> str:
"""过滤掉文本中的HTTP/HTTPS链接"""
import re
url_pattern = re.compile(r'https?://\S+')
return url_pattern.sub('', text)
# 在模块级别创建单例客户端
_ollama_client_instance = None
def llm_response(message, nerfreal: BaseReal):
start = time.perf_counter()
global _ollama_client_instance
if _ollama_client_instance is None:
_ollama_client_instance = OllamaClient(
model_name="llama3.2:3b", # 使用本地模型
base_url="http://localhost:11434" # Ollama本地服务地址
)
user_id = "fixed_user_id_for_session"
if user_id not in _ollama_client_instance.user_conversations:
_ollama_client_instance.user_conversations[user_id] = None
logger.info(f"新用户会话: {user_id}")
conversation_id = _ollama_client_instance.get_conversation_id(user_id)
logger.info(f"当前会话 - 用户: {user_id}, ID: {conversation_id}")
# 添加用户消息到历史记录
user_message = {
"role": "user",
"content": message
}
# 发起流式对话
_ollama_client_instance.stream_chat(
conversation_id=conversation_id,
user_id=user_id,
messages=[user_message],
on_message=lambda event, msg: None,
max_history_rounds=5,
nerfreal=nerfreal
)
end = time.perf_counter()
logger.info(f"LLM总耗时: {end-start:.2f}秒")
```
### 主要重构点说明:
1. **移除Coze依赖**:
- 删除所有Coze API相关的认证逻辑(JWT、access token等)
- 替换为Ollama的本地API调用`http://localhost:11434/api/chat`
2. **Ollama流式响应处理**:
```python
# 处理Ollama响应
if chunk.get("done", False):
wrapped_callback("done", {})
break
if "message" in chunk and "content" in chunk["message"]:
content = chunk["message"]["content"]
wrapped_callback("delta", {"content": content})
```
3. **本地会话管理**:
- 保留对话历史管理功能
- 使用JSON文件存储会话状态 (`ollama_sessions.json`)
- 增加会话超时处理逻辑
4. **简化客户端初始化**:
```python
_ollama_client_instance = OllamaClient(
model_name="llama3.2:3b",
base_url="http://localhost:11434"
)
```
5. **保持核心功能不变**:
- 流式响应处理
- 句子分割逻辑
- 历史记录管理
- 文件输出功能
### 使用前准备:
1. 安装并运行Ollama服务:
```bash
curl -fsSL https://ollama.com/install.sh | sh
ollama serve
```
2. 下载所需模型:
```bash
ollama pull llama3.2:3b
```
### 性能优化建议:
1. **模型选择**:使用量化模型(如`llama3.2:3b-q4_K_M`)提高响应速度
2. **批处理**:对于多轮对话,可考虑缓存上下文减少传输数据量
3. **硬件加速**:启用GPU加速(安装CUDA版本的Ollama)