From 5abfc83d4c6f42ad04e3e01b733b6f41009b2bfc Mon Sep 17 00:00:00 2001 From: liubiren Date: Thu, 3 Sep 2026 20:09:30 +0800 Subject: [PATCH] 1 --- agent/alembic/versions/3a74f94a7ad4_.py | 36 ++++++++ agent/application/pages/conversation.py | 6 +- agent/application/states/conversation.py | 100 +++++++++++++++------- agent/application/states/database.py | 39 ++++++--- agent/application/states/models.py | 56 ++++++------ agent/application/workshop/book_flight.py | 67 +++++++-------- agent/application/workshop/models.py | 2 +- agent/database.db | Bin 163840 -> 180224 bytes 8 files changed, 195 insertions(+), 111 deletions(-) create mode 100644 agent/alembic/versions/3a74f94a7ad4_.py diff --git a/agent/alembic/versions/3a74f94a7ad4_.py b/agent/alembic/versions/3a74f94a7ad4_.py new file mode 100644 index 0000000..413e5a8 --- /dev/null +++ b/agent/alembic/versions/3a74f94a7ad4_.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 3a74f94a7ad4 +Revises: d52f1ee0f9c4 +Create Date: 2026-09-03 12:30:13.538072 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = '3a74f94a7ad4' +down_revision: Union[str, Sequence[str], None] = 'd52f1ee0f9c4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('conversationrecord', schema=None) as batch_op: + batch_op.add_column(sa.Column('workflow', sqlmodel.sql.sqltypes.AutoString(), server_default=sa.text("('')"), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('conversationrecord', schema=None) as batch_op: + batch_op.drop_column('workflow') + + # ### end Alembic commands ### diff --git a/agent/application/pages/conversation.py b/agent/application/pages/conversation.py index 6f02cdb..e87a1c8 100644 --- a/agent/application/pages/conversation.py +++ b/agent/application/pages/conversation.py @@ -418,9 +418,9 @@ def thinking(item: MessageHistoryItem) -> rx.Component: ) -def result_output(item: MessageHistoryItem) -> rx.Component: +def output(item: MessageHistoryItem) -> rx.Component: """ - 结果输出 + 输出 :return: Component """ return rx.markdown( @@ -550,7 +550,7 @@ def message_history_item(item: MessageHistoryItem) -> rx.Component: item.type, (MessageType.USER_PROMPT, user_prompt(item)), (MessageType.THINKING, thinking(item)), - (MessageType.RESULT_OUTPUT, result_output(item)), + (MessageType.OUTPUT, output(item)), ), padding="0 16px", width="100%", diff --git a/agent/application/states/conversation.py b/agent/application/states/conversation.py index e51be98..897da2b 100644 --- a/agent/application/states/conversation.py +++ b/agent/application/states/conversation.py @@ -3,12 +3,13 @@ 会话状态 """ from datetime import datetime -from typing import AsyncGenerator +from typing import AsyncGenerator, cast from pydantic_ai.messages import ( FunctionToolCallEvent, FunctionToolResultEvent, LoadCapabilityCallPart, + ToolReturnPart, PartDeltaEvent, PartEndEvent, PartStartEvent, @@ -24,7 +25,7 @@ import reflex as rx from application.states.database import DatabaseState from application.states.models import ( - AgentRunWarmUpEvent, + AgentRunEvent, Conversation, ConversationHistoryItem, Message, @@ -32,9 +33,9 @@ from application.states.models import ( MessageType, Workflow, WorkflowType, - deps_dump_python, - deps_validate_python, - usage_validate_python, + workflow_dump_json, + workflow_validate_json, + usage_validate_json, ) @@ -306,6 +307,8 @@ class ConversationState(rx.State): # 初始化片段索引映射为消息实例唯一标识字典 index_map_to_message_id: dict[int, str] = {} + # 初始化工具调用片段工具名称映射为消息实例唯一标识字典 + tool_name_map_to_message_id: dict[str, str] = {} # 初始化工作流 if conversation.workflow: @@ -317,12 +320,12 @@ class ConversationState(rx.State): # 运行并流式输出事件 stream_events = run_stream_events( - deps=deps_validate_python(dict(conversation.workflow.deps)), + deps=dict(conversation.workflow.deps), user_prompt=user_prompt, message_history=await db_state.get_message_history( conversation_id=self.actived_conversation_id ), - usage=usage_validate_python(dict(conversation.usage)), + usage=conversation.usage, ) else: from application.workshop.talk import ( @@ -335,7 +338,7 @@ class ConversationState(rx.State): message_history=await db_state.get_message_history( conversation_id=self.actived_conversation_id ), - usage=usage_validate_python(dict(conversation.usage)), + usage=conversation.usage, ) # 消息列表 @@ -352,7 +355,7 @@ class ConversationState(rx.State): part=part, ): match part: - # 思考分片开始事件 + # 思考分片 case ThinkingPart(content=content): # 构建消息实例 message = Message( @@ -367,10 +370,24 @@ class ConversationState(rx.State): index_map_to_message_id[index] = message.id yield # 通知前端更新渲染 - # 文本分片开始事件 + # 工具调用分片 + case ToolCallPart(tool_name=tool_name): + # 构建消息实例 + message = Message( + type=MessageType.TOOL_CALL, + title=f"正在{tool_name}", + is_running=True, + ) + # 添加至消息字典 + conversation.messages[message.id] = message + # 将消息实例唯一标识与工具调用片段工具名称映射 + tool_name_map_to_message_id[tool_name] = message.id + yield # 通知前端更新渲染 + + # 文本分片 case TextPart(content=content): # 构建消息实例 - message = Message(type=MessageType.TEXT, content=content) + message = Message(type=MessageType.OUTPUT, content=content) # 添加至消息字典 conversation.messages[message.id] = message # 将消息实例唯一标识与片段索引映射 @@ -380,7 +397,7 @@ class ConversationState(rx.State): # ========== 增量事件 ========== case PartDeltaEvent(index=index, delta=delta): match delta: - # 思考分片增量事件 + # 思考分片增量 case ThinkingPartDelta( content_delta=content_delta, ): @@ -391,7 +408,7 @@ class ConversationState(rx.State): ].content += content_delta yield # 通知前端更新渲染 - # 文本分片增量事件 + # 文本分片增量 case TextPartDelta( content_delta=content_delta, ): @@ -407,17 +424,18 @@ class ConversationState(rx.State): part=part, ): match part: - # 思考分片结束事件 + # 思考分片 case ThinkingPart(content=content): # 获取消息实例 message = conversation.messages[ index_map_to_message_id[index] ] message.is_running = False - message.title = "思考完成" + message.title = "思考已完成" yield # 通知前端更新渲染 messages.append(message) + # 文本分片 case TextPart(content=content): # 获取消息实例 message = conversation.messages[ @@ -425,11 +443,27 @@ class ConversationState(rx.State): ] messages.append(message) - # ========== 智能体运行预热事件 ========== - case AgentRunWarmUpEvent(content=content): + # ========== 工具调用结果事件 ========== + case FunctionToolResultEvent(part=part): + match part: + # 工具返回分片 + case ToolReturnPart(tool_name=tool_name, content=content): + # 获取消息实例 + message = conversation.messages[ + tool_name_map_to_message_id[tool_name] + ] + message.title = f"{tool_name}已完成" + message.content = cast( + str, content + ) # 约定工具返回分片内容必为字符串 + yield # 通知前端更新渲染 + messages.append(message) + + # ========== 智能体运行事件 ========== + case AgentRunEvent(content=content): # 构建消息实例 message = Message( - type=MessageType.TEXT, + type=MessageType.OUTPUT, content=content, ) # 添加至消息字典 @@ -439,9 +473,11 @@ class ConversationState(rx.State): # ========== 智能体运行结果事件 ========== case AgentRunResultEvent(result=result): - # 更新使用量 + # 更新会话记录 await db_state.update_conversation_record( - conversation.id, usage=result.usage + conversation.id, + usage=result.usage, + workflow=conversation.workflow, ) # 创建运行记录 await db_state.create_run_record( @@ -449,18 +485,18 @@ class ConversationState(rx.State): new_messages=result.new_messages(), ) - # 将正在运行设置为否 - conversation.is_running = False - # 将等待流式输出设置为否 - conversation.awaiting_stream = False - self.conversations[self.actived_conversation_id] = conversation - yield + # 将正在运行设置为否 + conversation.is_running = False + # 将等待流式输出设置为否 + conversation.awaiting_stream = False + self.conversations[self.actived_conversation_id] = conversation + yield - # 批量创建消息记录 - await db_state.create_message_records( - conversation_id=conversation.id, - messages=messages, - ) + # 批量创建消息记录 + await db_state.create_message_records( + conversation_id=conversation.id, + messages=messages, + ) @rx.event async def set_workflow(self, type: WorkflowType) -> None: @@ -477,7 +513,7 @@ class ConversationState(rx.State): from application.workshop.book_flight import deps # 初始化预定航班工作流 - workflow = Workflow(type=type, deps=deps_dump_python(deps)) + workflow = Workflow(type=type, deps=deps) # 设置用户提示词 self.set_user_prompt( f"帮我查询并预定在 {deps.date.strftime('%Y-%m-%d')} 从 {deps.origin_airport_code} 到 {deps.destination_airport_code} 的航班" diff --git a/agent/application/states/database.py b/agent/application/states/database.py index e0b6382..44baf05 100644 --- a/agent/application/states/database.py +++ b/agent/application/states/database.py @@ -4,8 +4,7 @@ """ from datetime import datetime, timedelta from random import choices -from typing import Any - +from dataclasses import asdict from pydantic_ai import ModelMessage, ModelMessagesTypeAdapter, RunUsage from pydantic_ai._uuid import uuid7 import reflex as rx @@ -15,7 +14,11 @@ from application.states.models import ( Conversation, Message, MessageType, + Workflow, + usage_dump_json, usage_validate_json, + workflow_dump_json, + workflow_validate_json, ) @@ -72,19 +75,19 @@ class ConversationRecord(SQLModel, table=True): ) user_id: str = Field(..., index=True, description="用户唯一标识") description: str = Field(default="新会话", description="会话描述") - usage: str = Field(default="", description="会话使用量") - workflow: str = Field( - default="", - description="会话工作流", + created_at: datetime = Field( + default_factory=datetime.now, description="会话创建时间" ) is_deleted: bool = Field( default=False, index=True, description="会话已删除:True 表示已删除,False 表示未删除", ) - created_at: datetime = Field( - default_factory=datetime.now, description="会话创建时间" - ) + usage: str = Field(default="", description="会话使用量") + workflow: str = Field( + default="", + description="会话工作流", + ) # 原则上嵌套列表或字典若不涉及查询则以字符串储存 class RunRecord(SQLModel, table=True): @@ -224,7 +227,7 @@ class DatabaseState(rx.State): conversation_record.usage ), # 将 conversation_record.usage 由 JSON 字符串反序列化为 RunUsage created_at=conversation_record.created_at, - workflow=_validate_json( + workflow=workflow_validate_json( conversation_record.workflow ), # 将 conversation_record.workflow 由 JSON 字符串反序列化为 Workflow, ), @@ -257,7 +260,9 @@ class DatabaseState(rx.State): record.id: Conversation( id=record.id, description=record.description, - usage=record.usage, + usage=usage_validate_json( + record.usage + ), # 将 record.usage 由 JSON 字符串反序列化为 RunUsage messages={}, # 新会话默认消息字典为空 created_at=record.created_at, ) @@ -269,6 +274,7 @@ class DatabaseState(rx.State): description: str | None = None, is_deleted: bool | None = None, usage: RunUsage | None = None, + workflow: Workflow | None = None, ) -> None: """ 更新会话记录 @@ -276,6 +282,7 @@ class DatabaseState(rx.State): :param description: 会话描述 :param is_deleted: 会话已删除 :param usage: 使用量 + :param workflow: 工作流 :return: None """ async with rx.asession() as session: @@ -287,7 +294,13 @@ class DatabaseState(rx.State): if isinstance(is_deleted, bool): record.is_deleted = is_deleted if isinstance(usage, RunUsage): - record.usage = usage_dump_python(usage) + record.usage = usage_dump_json( + RunUsage(**asdict(usage)) + ) # 将 RunUsage 序列化为 JSON 字符串 + if isinstance(workflow, Workflow): + record.workflow = workflow_dump_json( + Workflow(**asdict(workflow)) + ) # 将 Workflow 序列化为 JSON 字符串 await session.commit() async def create_message_record( @@ -375,7 +388,7 @@ class DatabaseState(rx.State): conversation_id=conversation_id, new_messages=ModelMessagesTypeAdapter.dump_json( new_messages - ).decode(), + ).decode("utf-8"), # 将 messages 由 List[ModelMessage] 序列化为 JSON 字符串 ) ) diff --git a/agent/application/states/models.py b/agent/application/states/models.py index 77d0463..1afeb88 100644 --- a/agent/application/states/models.py +++ b/agent/application/states/models.py @@ -9,17 +9,24 @@ from typing import Any from pydantic import BaseModel, Field, TypeAdapter from pydantic_ai import RunUsage from pydantic_ai._uuid import uuid7 -from pydantic_ai.usage import UsageLimits -class AgentRunWarmUpEvent(BaseModel): + +class NoResult(BaseModel): """ - 智能体运行预热事件类 + 无结果类 + """ + + ... + +class AgentRunEvent(BaseModel): + """ + 智能体运行事件类 """ content: str = Field( default="", - description="预热内容", + description="智能体运行内容", ) @@ -30,9 +37,8 @@ class MessageType(StrEnum): USER_PROMPT = "user_prompt" THINKING = "thinking" - WORK = "work" - RESULT_OUTPUT = "result_output" - TEXT = "text" + TOOL_CALL = "tool_call" + OUTPUT = "output" class Message(BaseModel): @@ -66,7 +72,7 @@ class Workflow(BaseModel): """ type: WorkflowType = Field(..., description="工作类型") - deps: dict[str, Any] = Field(default_factory=dict, description="工作依赖项") + deps: Any = Field(default=None, description="工作依赖项") class Conversation(BaseModel): @@ -76,10 +82,11 @@ class Conversation(BaseModel): id: str = Field(..., description="会话唯一标识") description: str = Field(..., description="会话描述") - user_prompt: str = Field(default="", description="用户提示词") + created_at: datetime = Field(..., description="会话创建日期时间") usage: RunUsage = Field(..., description="会话使用量") messages: dict[str, Message] = Field(default_factory=dict, description="消息字典") - created_at: datetime = Field(..., description="会话创建日期时间") + workflow: Workflow | None = Field(default=None, description="工作流") + user_prompt: str = Field(default="", description="用户提示词") is_running: bool = Field( default=False, description="正在运行,True 表示正在运行, False 表示运行结束" ) @@ -87,7 +94,6 @@ class Conversation(BaseModel): default=False, description="等待流式输出,True 表示等待流式输出,False 表示已开始流式输出或已完成", ) - workflow: Workflow | None = Field(default=None, description="工作流") class ConversationHistoryItem(BaseModel): @@ -106,26 +112,26 @@ class MessageHistoryItem(Message): """ -# 依赖项适配器 -DepsAdapter = TypeAdapter(Any) +# 工作流适配器 +WorkflowAdapter = TypeAdapter(Any) -def deps_validate_python(deps: dict[str, Any]) -> Any: +def workflow_validate_json(workflow: str) -> Any: """ - 将依赖项反序列化 + 将工作流反序列化 """ - if not deps: + if not workflow: return None - return DepsAdapter.validate_python(deps) + return WorkflowAdapter.validate_json(workflow) -def deps_dump_python(deps: Any) -> dict[str, Any]: +def workflow_dump_json(workflow: Any) -> str: """ - 将依赖项序列化 - :param deps: 依赖项 - :return: python 字典 + 将工作流序列化 + :param workflow: 工作流 + :return: 字符串 """ - return DepsAdapter.dump_python(deps) + return WorkflowAdapter.dump_json(workflow).decode("utf-8") # 使用量(RunUsage)适配器 @@ -141,10 +147,10 @@ def usage_validate_json(usage: str) -> RunUsage: return UsageAdapter.validate_json(usage) -def usage_dump_json(usage: RunUsage) -> dict[str, Any]: +def usage_dump_json(usage: RunUsage) -> str: """ 将使用量序列化 :param usage: 使用量 - :return: python 字典 + :return: 字符串 """ - return UsageAdapter.dump_python(usage) + return UsageAdapter.dump_json(usage).decode("utf-8") diff --git a/agent/application/workshop/book_flight.py b/agent/application/workshop/book_flight.py index 90f0e84..85a2a9c 100644 --- a/agent/application/workshop/book_flight.py +++ b/agent/application/workshop/book_flight.py @@ -3,8 +3,9 @@ 预定航班工作流(范式) """ import datetime -from typing import AsyncGenerator, cast - +from re import I +from typing import AsyncGenerator, cast, Any +from dataclasses import replace from pydantic import BaseModel, Field, field_validator from pydantic_ai import ( Agent, @@ -26,10 +27,10 @@ from pydantic_ai.messages import ( ) from pydantic_ai.run import AgentRunResult, AgentRunResultEvent -from application.states.models import AgentRunWarmUpEvent +from application.states.models import AgentRunEvent, NoResult from application.workshop.models import ( DEEPSEEK_V4_FLASH_MODEL, - DEEPSEEK_V4_FLASH_MODEL_DISABLED_THINKING, + DEEPSEEK_V4_FLASH_MODEL_SETTINGS, ) @@ -57,14 +58,6 @@ class Flight(BaseModel): return airport_code -class NoResult(BaseModel): - """ - 无结果类 - """ - - ... - - class Deps(BaseModel): """ 依赖类 @@ -76,16 +69,13 @@ class Deps(BaseModel): searched_flights: list[Flight] | None = Field( default=None, description="查询到的航班" ) - extracted_flight_number: str | None = Field( - default=None, description="提取到的航班号" - ) booked_flight: Flight | None = Field(default=None, description="预定到的航班") # 主智能体 agent = Agent[Deps, Flight | NoResult | DeferredToolRequests]( model=DEEPSEEK_V4_FLASH_MODEL, - model_settings=DEEPSEEK_V4_FLASH_MODEL_DISABLED_THINKING, + model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS, deps_type=Deps, output_type=Flight | NoResult | DeferredToolRequests, system_prompt=( @@ -99,7 +89,7 @@ agent = Agent[Deps, Flight | NoResult | DeferredToolRequests]( # 提取所有航班信息智能体 extraction_flights_agent = Agent[Deps, list[Flight]]( model=DEEPSEEK_V4_FLASH_MODEL, - model_settings=DEEPSEEK_V4_FLASH_MODEL_DISABLED_THINKING, + model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS, deps_type=Deps, output_type=list[Flight], system_prompt=( @@ -113,7 +103,7 @@ extraction_flights_agent = Agent[Deps, list[Flight]]( # 提取航班号智能体 extraction_flight_number_agent = Agent[Deps, str | None]( model=DEEPSEEK_V4_FLASH_MODEL, - model_settings=DEEPSEEK_V4_FLASH_MODEL_DISABLED_THINKING, + model_settings=DEEPSEEK_V4_FLASH_MODEL_SETTINGS, deps_type=Deps, output_type=str | None, system_prompt=( @@ -195,7 +185,7 @@ async def search_flights(ctx: RunContext[Deps]) -> list[Flight]: raise ModelRetry(f"提取到的所有航班信息不可能为空") # 查询到的航班 - ctx.deps.searched_flights = sorted( + searched_flights = sorted( [ flight for flight in extracted_flights @@ -205,10 +195,14 @@ async def search_flights(ctx: RunContext[Deps]) -> list[Flight]: ], key=lambda flight: flight.airfare, ) - return ctx.deps.searched_flights + if not searched_flights: # 模拟业务规则约束:查询到的航班不能为空 + raise ModelRetry("查询到的航班不能为空") + + ctx.deps.searched_flights = searched_flights + return searched_flights -@agent.tool(name="预定航班") +@agent.tool(name="预定航班", requires_approval=True) async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: """ 预定航班 @@ -220,25 +214,24 @@ async def book_flight(ctx: RunContext[Deps]) -> Flight | NoResult: raise ApprovalRequired() # 提取到的航班号 - ctx.deps.extracted_flight_number = ( + extracted_flight_number = ( await extraction_flight_number_agent.run( - f"用户提示词:\n{ctx.prompt}\n查询到的航班:\n{(flight_numbers:= [flight.number for flight in searched_flights])}", + f"用户提示词:\n{cast(str, ctx.prompt).upper()}\n查询到的航班:\n{(flight_numbers:= [flight.number for flight in searched_flights])}", deps=ctx.deps, usage=ctx.usage, usage_limits=UsageLimits(request_limit=10), ) ).output - if not ctx.deps.extracted_flight_number: + if not extracted_flight_number: return NoResult() - if ctx.deps.extracted_flight_number not in flight_numbers: - raise ModelRetry( - f"提取到的航班号 {ctx.deps.extracted_flight_number} 不在查询到的航班中" - ) + + if extracted_flight_number not in flight_numbers: + raise ModelRetry(f"提取到的航班号 {extracted_flight_number} 不在查询到的航班中") # 预定到的航班 ctx.deps.booked_flight = next( flight for flight in searched_flights - if flight.number == ctx.deps.extracted_flight_number + if flight.number == extracted_flight_number ) return ctx.deps.booked_flight @@ -278,7 +271,7 @@ deps = Deps( async def run_stream_events( - deps: Deps, + deps: Any, user_prompt: str, message_history: list[ModelMessage], usage: RunUsage, @@ -286,15 +279,15 @@ async def run_stream_events( deferred_tool_results: DeferredToolResults | None = None, ) -> AsyncGenerator[ AgentStreamEvent - | AgentRunResultEvent[str | DeferredToolRequests] - | AgentRunWarmUpEvent + | AgentRunResultEvent[Flight | NoResult | DeferredToolRequests] + | AgentRunEvent | None, ]: """ 运行并流式输出事件 """ - # 构建智能体运行预热事件 - yield AgentRunWarmUpEvent( + # 构建智能体运行事件 + yield AgentRunEvent( content=f"正在预定 {deps.date.strftime('%Y-%m-%d')} 从 {deps.origin_airport_code} 到 {deps.destination_airport_code} 的航班" ) tool_names = set() @@ -351,9 +344,8 @@ async def run_stream_events( case AgentRunResult(output=output): match output: case Flight(): - yield AgentRunResultEvent( - result=AgentRunResult(output="预定成功") - ) + yield AgentRunEvent(content="预定成功") + case DeferredToolRequests(approvals=approvals): yield AgentRunResultEvent( result=AgentRunResult( @@ -362,3 +354,4 @@ async def run_stream_events( ) ) ) + yield event diff --git a/agent/application/workshop/models.py b/agent/application/workshop/models.py index 2de7b9a..373f4b8 100644 --- a/agent/application/workshop/models.py +++ b/agent/application/workshop/models.py @@ -14,6 +14,6 @@ DEEPSEEK_V4_FLASH_MODEL = OpenAIChatModel( ), ) -DEEPSEEK_V4_FLASH_MODEL_DISABLED_THINKING = ModelSettings( +DEEPSEEK_V4_FLASH_MODEL_SETTINGS = ModelSettings( temperature=0, extra_body={"thinking": {"type": "disabled"}} # 温度控制 ) # 不启用思考模式 diff --git a/agent/database.db b/agent/database.db index 74f44ce0efdc6a7dc0b1bd60a148a6830ef21da3..5f320ecb43ad3e134836aa37daf8732a58f000de 100644 GIT binary patch delta 21348 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