Python/utils/agent.py

371 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
智能体模块
"""
# 列举导入模块
from pathlib import Path
import time
from typing import AsyncGenerator, List, Optional, Dict, Literal
from uuid import uuid4
from pydantic_ai.messages import ModelMessage
from pydantic_ai import Agent as PydanticAIAgent, ModelMessage
from pydantic_ai.capabilities import AgentCapability
from asyncio import Task, create_task, sleep, Queue
from pydantic import Field
from pydantic import BaseModel
from pydantic_ai.messages import (
ModelMessagesTypeAdapter,
ModelResponse,
ModelMessageEvent,
PartStartEvent,
PartDeltaEvent,
PartEndEvent,
TextPart,
ToolSearchCallPart,
NativeToolSearchCallPart,
LoadCapabilityCallPart,
ThinkingPart,
ToolCallPart,
NativeToolCallPart,
TextPartDelta,
ThinkingPartDelta,
ModelResponseStreamEvent,
ToolReturnPart,
AgentRunResultEvent,
NativeToolReturnPart,
)
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.output import OutputSpec
from pydantic_ai.providers.openai import OpenAIProvider
from .sqlite import SQLite
class AgentMemory(SQLite):
"""
智能体记忆体,支持:
create新增对话消息
read查询会话历史消息
"""
def __init__(self):
"""
初始化
"""
# 构建数据库路径
super().__init__(database=Path(__file__).parent.resolve() / "agent_memory.db")
try:
with self:
self.execute(
sql="""
CREATE TABLE IF NOT EXISTS new_messages
(
--唯一标识
id TEXT PRIMARY KEY,
--会话唯一标识
session_id TEXT NOT NULL,
--新对话消息
new_messages TEXT NOT NULL,
--时间戳(毫秒)
timestamp INTEGER NOT NULL
)
"""
)
except Exception as exception:
raise RuntimeError(f"初始化数据库发生异常:{str(exception)}") from exception
def create_new_messages(
self, session_id: str, new_messages: List[ModelMessage]
) -> bool:
"""
新增新对话消息
:param session_id: 会话唯一标识
:param new_messages: 新对话消息
:return: 新增是否成功
"""
try:
with self:
return self.execute(
sql="""
INSERT INTO new_messages (id, session_id, new_messages, timestamp) VALUES (?, ?, ?, ?)
""",
parameters=(
uuid4().hex.lower(),
session_id,
ModelMessagesTypeAdapter.dump_json(new_messages),
int(time.time() * 1000),
),
)
except Exception as exception:
raise RuntimeError(f"新增对话消息发生异常:{str(exception)}") from exception
def get_message_history(self, session_id: str) -> List[ModelMessage]:
"""
获取指定会话的消息历史
:param session_id: 会话唯一标识
:return: 消息历史
"""
try:
with self:
result = self.query_all(
sql="""
SELECT new_messages
FROM new_messages
WHERE session_id = ?
ORDER BY timestamp ASC
""",
parameters=(session_id,),
)
message_history = []
for row in result:
message_history.extend(
ModelMessagesTypeAdapter.validate_json(row["new_messages"])
)
return message_history
except Exception as exception:
raise RuntimeError(
f"查询会话历史消息发生异常:{str(exception)}"
) from exception
class PartCache(BaseModel):
"""
片段缓存模型
"""
type_: Literal["thinking", "text"] = Field(..., description="片段类型")
content: str = Field(..., description="片段内容")
content_delta: str = Field(default="", description="片段内容增量")
task: Optional[Task] = Field(default=None, description="片段绑定的异步协程任务")
class EventsDebouncer:
"""
模型消息事件防抖器
"""
def __init__(self, delay: float = 0.2, content_delta_limit: int = 50):
"""
初始化
:param delay: 异步协程任务延迟时间(秒)
:param content_delta_limit: 增量片段内容的字数上限
"""
self.delay = delay
self.content_delta_limit = content_delta_limit
# 所有片段缓存
self.part_caches: Dict[int, PartCache] = {}
# 待刷新列队
self.pending_flush_queue = Queue()
async def handle_event(
self, event: ModelMessageEvent
) -> AsyncGenerator[ModelMessageEvent, None]:
"""
处理模型消息事件:合并片段增量事件
:param event: 模型消息事件
:yield: AsyncGenerator
"""
# 从待刷新列队中返回刷新事件
async for flush_event in self._yield_pending_flush_events():
yield flush_event
# 若为片段开始事件且片段类型为思考或纯文本则创建片段缓存,否则直接返回
if isinstance(event, PartStartEvent):
index, part = event.index, event.part
match part:
case ThinkingPart():
type_ = "thinking"
case TextPart():
type_ = "text"
case _:
yield event
return
# 创建片段缓存
self.part_caches[index] = PartCache(
type_=type_,
content=part.content or "",
)
yield event
return
# 若为片段增量事件且增量类型为思考或纯文本则更新片段内容和增量片段内容并刷新片段缓存,否则直接返回
if isinstance(event, PartDeltaEvent):
index, delta = event.index, event.delta
part_cache = self.part_caches.get(index)
if part_cache and isinstance(delta, (ThinkingPartDelta, TextPartDelta)):
part_cache.content += delta.content_delta or ""
part_cache.content_delta += delta.content_delta or ""
# 取消异步协程任务
self._cancel_task(index=index)
# 创建异步协程任务
part_cache.task = create_task(coro=self._delay_flush(index=index))
else:
yield event
return
# 若为其它事件则先刷新所有片段缓存再直接返回
async for flush_event in self._batch_flush():
yield flush_event
yield event
# 若为片段结束事件则删除该片段缓存
if isinstance(event, PartEndEvent):
self.part_caches.pop(event.index, None)
return
async def _yield_pending_flush_events(
self,
) -> AsyncGenerator[ModelMessageEvent, None]:
"""
从待刷新列队中返回刷新事件
:return: AsyncGenerator
"""
while not self.pending_flush_queue.empty():
flush_event = await self.pending_flush_queue.get_nowait()
yield flush_event
def _cancel_task(self, index: int) -> None:
"""
取消异步协程任务
:param index: 片段索引
:yield: None
"""
part_cache = self.part_caches.get(index)
if part_cache and part_cache.task and not part_cache.task.done():
part_cache.task.cancel()
part_cache.task = None
async def _flush(self, index: int) -> Optional[PartDeltaEvent]:
"""刷新片段缓存"""
part_cache = self.part_caches.get(index)
if not part_cache or not part_cache.content_delta:
return None
match part_cache.type_:
case "thinking":
delta = ThinkingPartDelta(content_delta=part_cache.content_delta)
case "text":
delta = TextPartDelta(content_delta=part_cache.content_delta)
part_cache.content_delta = ""
part_cache.task = None
return PartDeltaEvent(index=index, delta=delta)
async def _delay_flush(self, index: int):
"""
延迟刷新片段缓存
:param index: 片段索引
:return: None
"""
await sleep(delay=self.delay)
flush_event = await self._flush(index=index)
if flush_event:
await self.pending_flush_queue.put(item=flush_event)
async def _batch_flush(self):
"""
批量刷新片段缓存
"""
for index in list(self.part_caches.keys()):
# 刷新片段缓存
flush_event = await self._flush(index)
if flush_event:
yield flush_event
class Agent:
"""
智能体,支持:
1 实例智能体
2 异步运行
"""
def __init__(
self,
session_id: str,
instructions: str,
output_type: OutputSpec = str,
capabilities: Optional[List[AgentCapability]] = None,
):
"""
初始化智能体
:param session_id: 会话唯一标识
:param instructions: 指令
:param skills: 智能体技能列表,默认为不使用技能
:param output_type: 输出类型
:return: 智能体实例
"""
# 会话唯一标识
self.session_id = session_id
# 实例智能体的记忆体
self.agent_memory = AgentMemory()
# 实例智能体
self.agent = PydanticAIAgent(
model=OpenAIChatModel(
model_name="deepseek-v4-flash",
provider=OpenAIProvider(
base_url="https://tokenhub.tencentmaas.com/v1",
api_key="sk-D9Y1mCe8VlvNqLuSC4mAjqEwxJ2nW4C0h8a7EPn8kg9RLsHq",
),
),
instructions=instructions,
capabilities=capabilities,
output_type=output_type,
retries=1,
)
self.agent.to_web()
async def stream_messages_events(
self, user_prompt: str | List[str]
) -> AsyncGenerator[str, None]:
"""
流式输出消息事件
:param user_prompt: 用户提示词(用户输入消息)
:return: 消息事件
"""
"""定义一次会话session包含若干论对话turn每一轮对话由用户输入消息message和智能体输出消息组成"""
# 获取指定会话的消息历史
message_history = self.agent_memory.get_message_history(
session_id=self.session_id
)
async with self.agent.run_stream_events(
user_prompt=user_prompt,
message_history=message_history,
) as events:
async for event in events:
# 处理片段开始事件
if isinstance(event, PartStartEvent):
part = event.part
if isinstance(part, ThinkingPart): # 处理思考片段
if content := part.content:
yield f"00:{content}"
elif isinstance(part, TextPart): # 处理文本片段
if content := part.content:
yield f"01:{content}"
elif isinstance(
part,
(
NativeToolSearchCallPart,
NativeToolCallPart,
ToolSearchCallPart,
ToolCallPart,
LoadCapabilityCallPart,
),
): # 处理原生搜索、原生工具、自封搜索、自封工具和加载能力调用片段
yield f"02:技能名称:{part.tool_name}"
self.agent_memory.create_new_messages(
session_id=self.session_id,
new_messages=events.new_messages(),
)