Python/utils/agent.py

369 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, QueueEmpty
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 PendingFlushPart(BaseModel):
"""
待刷新模型消息片段
"""
type_: Literal["thinking", "text"] = Field(..., description="片段类型")
content_delta: str = Field(default="", description="片段内容增量")
task: Optional[Task] = Field(default=None, description="异步协程任务")
class EventsDebouncer:
"""
模型消息事件防抖器
使用 Pydantic-AI 中 run_stream_events 方法时需就其返回的模型消息事件处理,延迟刷新片段类型为思考或文本的片段增量事件以实现防抖(打字机效果)
在 Pydantic-AI 中模型流式输出包含若干片段,例如思考片段、文本片段等。每个片段包含若干模型消息事件,例如片段开始事件、片段增量事件、片段结束事件等
"""
def __init__(self, flush_delay: float = 0.2):
"""
初始化
:param flush_delay: 刷新延迟(单位为秒)
"""
self.flush_delay = flush_delay
# 待刷新模型响应片段
self.pending_flush_parts: Dict[int, PendingFlushPart] = {}
# 待刷新列队
self.pending_flush_queue = Queue()
async def handle_event(
self, event: ModelMessageEvent
) -> AsyncGenerator[ModelMessageEvent, None]:
"""
处理模型消息事件
:param event: 模型消息事件
:yield: AsyncGenerator
"""
# 返回待刷新列队中模型消息事件
while True:
try:
yield self.pending_flush_queue.get_nowait()
except QueueEmpty:
break
# 若为思考或文本的片段开始事件则先缓存片段再返回,其它事件直接返回
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.pending_flush_parts[index] = PendingFlushPart(
type_=type_,
)
yield event
return
# 若为思考或文本的片段增量事件则更新缓存,其它事件直接返回
if isinstance(event, PartDeltaEvent):
index, delta = event.index, event.delta
pending_flush_part = self.pending_flush_parts.get(index)
if pending_flush_part and isinstance(
delta, (ThinkingPartDelta, TextPartDelta)
):
pending_flush_part.content_delta += delta.content_delta or ""
# 原理:若持续输出片段增量事件则拦截并重新创建异步协程任务,至输出间隔超过刷新延迟则返回
# 取消异步协程任务
self._cancel_task(index=index)
# 创建异步协程任务
pending_flush_part.task = create_task(
coro=self._delay_flush(index=index)
)
else:
yield event
return
# 若为其它事件则先刷新所有片段缓存再直接返回
for index in list(self.pending_flush_parts.keys()):
flush_event = await self._flush(index=index)
if flush_event:
yield flush_event
# 若为片段结束事件则删除该片段缓存
if isinstance(event, PartEndEvent):
self.part_caches.pop(event.index, None)
return
def _cancel_task(self, index: int) -> None:
"""
取消异步协程任务
:param index: 片段索引
:yield: None
"""
pending_flush_part = self.pending_flush_parts.get(index)
if not pending_flush_part or not pending_flush_part.task:
return
# 若异步协程任务未完成则取消
if not pending_flush_part.task.done():
pending_flush_part.task.cancel()
pending_flush_part.task = None
async def _delay_flush(self, index: int):
"""
延迟刷新
:param index: 片段索引
:return: None
"""
await sleep(delay=self.flush_delay)
flush_event = await self._flush(index=index)
if flush_event:
await self.pending_flush_queue.put(item=flush_event)
async def _flush(self, index: int) -> Optional[PartDeltaEvent]:
"""
刷新
:param index: 片段索引
:return: Optional[PartDeltaEvent]
"""
pending_flush_part = self.pending_flush_parts.get(index)
if not pending_flush_part or not pending_flush_part.content_delta:
return None
match pending_flush_part.type_:
case "thinking":
delta = ThinkingPartDelta(content_delta=pending_flush_part.content_delta)
case "text":
delta = TextPartDelta(content_delta=pending_flush_part.content_delta)
pending_flush_part.content_delta = ""
pending_flush_part.task = None
return PartDeltaEvent(index=index, delta=delta)
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(),
)