This commit is contained in:
liubiren 2026-09-03 20:09:30 +08:00
parent 7df4e51bbb
commit 5abfc83d4c
8 changed files with 195 additions and 111 deletions

View File

@ -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 ###

View File

@ -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%",

View File

@ -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(
@ -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} 的航班"

View File

@ -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 字符串
)
)

View File

@ -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")

View File

@ -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

View File

@ -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"}} # 温度控制
) # 不启用思考模式

Binary file not shown.