# UserAgentContext & ProfileSettings v1 设计 **Date:** 2026-03-05 **Status:** Approved --- ## 目标 为 Agent Runtime 提供完整的用户画像上下文,通过 Pydantic 约束 profiles.settings 结构,确保: 1. 运行时入口读取 profile(username/bio/settings) 2. settings 结构类型安全、版本可演进 3. 关键配置(语言/时区/国家)符合标准格式 --- ## 架构 ``` Profile (DB JSONB) ↓ ProfileSettings (Pydantic) ↓ UserAgentContext (DataClass) ↓ build_global_system_prompt(ctx) ``` **设计原则:** - 唯一入口:`get_user_agent_context(user_id)` 读取并构造上下文 - 不可变:UserAgentContext 使用 frozen dataclass - 向后兼容:version 字段预留未来演进 --- ## ProfileSettings v1 结构 ```json { "version": 1, "preferences": { "interface_language": "zh-CN", "ai_language": "zh-CN", "timezone": "Asia/Shanghai", "country": "CN" }, "privacy": {}, "notification": {} } ``` ### 字段说明 | 字段 | 类型 | 默认值 | 约束 | |------|------|--------|------| | `version` | int | 1 | 必须为 1(v1 锁定) | | `preferences.interface_language` | str | "zh-CN" | BCP-47 格式 | | `preferences.ai_language` | str | "zh-CN" | BCP-47 格式 | | `preferences.timezone` | str | "Asia/Shanghai" | IANA 时区 | | `preferences.country` | str | "CN" | ISO 3166-1 alpha-2 | | `privacy` | dict | {} | 空对象(预留) | | `notification` | dict | {} | 空对象(预留) | ### 约束规则 **1. BCP-47 语言格式** 正则:`^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$` 示例: - ✅ zh-CN, en-US, zh-TW, ja-JP - ❌ zh_CN, EN, chn **2. IANA 时区** 使用 `zoneinfo.ZoneInfo` 校验。 示例: - ✅ Asia/Shanghai, America/New_York, UTC - ❌ CST, GMT+8 **3. ISO 3166-1 alpha-2 国家代码** 使用 `pycountry.countries.get(alpha_2=...)` 校验。 示例: - ✅ CN, US, JP, GB - ❌ CHN, USA, zz --- ## UserAgentContext 结构 ```python @dataclass(frozen=True) class UserAgentContext: user_id: UUID username: str bio: str | None settings: ProfileSettings ``` **设计要点:** - 不可变(frozen=True):防止运行时修改 - 完整画像:包含身份(username/bio)和配置(settings) - 唯一构造入口:`get_user_agent_context(user_id)` --- ## Pydantic 模型实现 ```python from pydantic import BaseModel, Field, field_validator from dataclasses import dataclass from uuid import UUID import re class PreferenceSettings(BaseModel): interface_language: str = "zh-CN" ai_language: str = "zh-CN" timezone: str = "Asia/Shanghai" country: str = "CN" @field_validator("interface_language", "ai_language") @classmethod def validate_bcp47(cls, v: str) -> str: pattern = r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$" if not re.match(pattern, v): raise ValueError(f"Invalid BCP-47 language tag: {v}") return v @field_validator("timezone") @classmethod def validate_iana_timezone(cls, v: str) -> str: import zoneinfo try: zoneinfo.ZoneInfo(v) except Exception: raise ValueError(f"Invalid IANA timezone: {v}") return v @field_validator("country") @classmethod def validate_iso_country(cls, v: str) -> str: import pycountry if not pycountry.countries.get(alpha_2=v.upper()): raise ValueError(f"Invalid ISO 3166-1 alpha-2 country code: {v}") return v.upper() class ProfileSettings(BaseModel): version: int = Field(default=1, ge=1, le=1) preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) privacy: dict = Field(default_factory=dict) notification: dict = Field(default_factory=dict) @dataclass(frozen=True) class UserAgentContext: user_id: UUID username: str bio: str | None settings: ProfileSettings ``` --- ## 依赖项 需要添加到 `backend/pyproject.toml`: ```toml [project.dependencies] pycountry = ">=23.0.0" ``` --- ## 迁移策略 **数据库层:** - profiles.settings 保持 JSONB,不做 schema 变更 - 现有数据默认值:`{"version": 1, "preferences": {"country": "CN"}}` **应用层:** - 读取时:`ProfileSettings.model_validate(profile.settings or {})` - 写入时:`profile.settings = settings.model_dump()` --- ## 未来演进 **版本迁移:** - Pydantic 支持多版本共存 - 数据库不做破坏性变更 --- --- ## AG-UI 事件转发与落库策略 ### 核心原则 **1. 事件转发时机:** - 只有 organization 阶段完成后转发 AG-UI 事件 - AG-UI bridge 已实现底层机制,编排层控制转发时机 **2. 落库时机:** - 意图识别和任务执行阶段:落库但 seq 取负数(用于审计) - 结果反馈阶段:seq 取最新 seq 的绝对值 +1(用于展示) ### Seq 设计细节 **意图识别和任务执行阶段(审计用):** - seq 取负数(如 -1, -2) - role: "assistant"(标记为 agent 输出) - content: 阶段的完整输出(用于审计/调试) - 重建会话时通过 `WHERE seq > 0` 过滤,不展示给用户 **结果反馈阶段(展示用):** - seq 取正数(取最新负数的绝对值 +1) - role: "assistant" - content: OrganizationResult.assistant_text - 重建会话时通过 `WHERE seq > 0` 展示给用户 **示例:** ``` | seq | role | content | 展示 | |------|----------|----------------------------|------| | -2 | assistant| ExecutionResult (完整) | 否 | | -1 | assistant| IntentResult (完整) | 否 | | 1 | user | 用户输入 | 是 | | 2 | assistant| OrganizationResult | 是 | ``` ### 编排层职责 ```python @listen(intent_stage) async def persist_intent(self, state: FlowState) -> FlowState: # seq 取负数 seq = await message_repo.get_next_negative_seq(state.session_id) await message_repo.create( session_id=state.session_id, seq=seq, # 负数 role="assistant", content=state.intent_result.model_dump_json(), ... ) return state @listen(execution_stage) async def persist_execution(self, state: FlowState) -> FlowState: # seq 取负数 seq = await message_repo.get_next_negative_seq(state.session_id) await message_repo.create( session_id=state.session_id, seq=seq, # 负数 role="assistant", content=state.execution_result.model_dump_json(), ... ) return state @listen(organization_stage) async def finalize_flow(self, state: FlowState) -> FlowState: result = state.organization_result # seq 取正数(最新负数绝对值+1) seq = await message_repo.get_next_positive_seq(state.session_id) await message_repo.create( session_id=state.session_id, seq=seq, # 正数 role="assistant", content=result.assistant_text, ... ) # 触发 AG-UI 事件(由 bridge 处理) return state ``` ### Token 和 Cost 累加 **策略:在内存中累加所有阶段的 token 和 cost,organization 完成后统一落库。** ```python @dataclass class FlowState: # ... tokens: dict[str, dict] = field(default_factory=dict) cost: Decimal = Decimal("0") currency: str = "CNY" ``` --- ## CrewAI Flow 三阶段设计 ### 架构概览 ``` User Input + UserAgentContext ↓ @start() begin() ↓ @listen() intent_stage() → 判断 can_answer_directly ↓ (router) ├─ DIRECT_RESPONSE → 直接返回 └─ NEEDS_EXECUTION ↓ @listen() execution_stage() → 任务执行/工具调用 ↓ @listen() organization_stage() → 结果组织与表达 ↓ 返回给用户 ``` ### 三阶段职责 **1. Intent Recognition(意图识别)** - Agent Type: `INTENT_RECOGNITION` - 输出结构(最小化设计): ```python class IntentResult(BaseModel): direct_answer: bool # 是否可以直接回答 intent_analysis: str # 意图分析文本(用于调试/审计) execution_prompt: str # 给 execution 阶段的提示词(direct_answer=false时使用) direct_response: str # 直接回复文本(direct_answer=true时使用) ``` - 短路逻辑: - `direct_answer=true` → 完全跳过 execution 和 organization,直接返回 direct_response - `direct_answer=false` → 进入 execution 阶段 - 输出约束:使用 `output_pydantic=IntentResult` - **落库策略**:落库到 messages 表,但重建会话时不展示 **2. Task Execution(任务执行)** - Agent Type: `TASK_EXECUTION` - 输入:IntentResult.execution_prompt + IntentResult.intent_analysis - 职责: - 执行复杂任务(查询数据库、调用工具、多步骤推理) - 返回结构化执行结果 - 输出结构(最小化设计): ```python class ExecutionResult(BaseModel): execution_summary: str # 任务执行摘要(用于调试/审计) organization_prompt: str # 给 organization 阶段的提示词 execution_data: dict = {} # 执行结果的结构化数据 ``` - 输出约束:使用 `output_pydantic=ExecutionResult` - **落库策略**:落库到 messages 表,但重建会话时不展示 **3. Result Reporting(结果报告)** - Agent Type: `RESULT_REPORTING` - 输入: - IntentResult(意图识别结果) - ExecutionResult(任务执行情况) - 职责: - 结合意图分析和执行结果,格式化为用户友好的响应 - 应用个性化模板(基于 UserAgentContext) - 输出结构(最小化设计): ```python class OrganizationResult(BaseModel): assistant_text: str # 最终回复文本 response_metadata: dict = {} # 响应元数据(可选) ``` - 输出约束:使用 `output_pydantic=OrganizationResult` - **唯一展示阶段**:重建会话时只展示此阶段的 message - **唯一转发阶段**:只有此阶段的输出需要通过 AG-UI 事件转发 ### Flow 状态管理 ```python @dataclass class FlowState: user_input: str context: UserAgentContext stage_trace: list[str] = field(default_factory=list) intent_result: IntentResult | None = None execution_result: ExecutionResult | None = None organization_result: OrganizationResult | None = None assistant_text: str = "" tokens: dict = field(default_factory=dict) cost: Decimal = Decimal("0") ``` ### 数据流向 ``` User Input + UserAgentContext ↓ @start() begin() ↓ @listen() intent_stage() ├─ IntentResult.direct_answer=true │ ↓ │ 跳过 execution,直接 organization │ ↓ │ organization_stage(IntentResult.next_stage_prompt, IntentResult.metadata) │ ↓ │ OrganizationResult → AG-UI 事件 + 落库 │ └─ IntentResult.direct_answer=false ↓ execution_stage(IntentResult.next_stage_prompt, IntentResult.metadata) ↓ ExecutionResult ↓ organization_stage(ExecutionResult.next_stage_prompt, ExecutionResult.metadata) ↓ OrganizationResult → AG-UI 事件 + 落库 ``` ### 三阶段输出约束 **所有阶段使用 `output_pydantic` 约束输出:** ```python from pydantic import BaseModel class IntentResult(BaseModel): direct_answer: bool next_stage_prompt: str metadata: dict = {} class ExecutionResult(BaseModel): next_stage_prompt: str metadata: dict = {} class OrganizationResult(BaseModel): assistant_text: str metadata: dict = {} # Task 定义 intent_task = Task( description="Analyze user intent", expected_output="Intent analysis", agent=intent_agent, output_pydantic=IntentResult, ) execution_task = Task( description="Execute tasks", expected_output="Execution result", agent=execution_agent, output_pydantic=ExecutionResult, ) organization_task = Task( description="Format response", expected_output="User-friendly response", agent=organization_agent, output_pydantic=OrganizationResult, ) ``` --- ## 系统选模逻辑设计 ### 问题背景 旧逻辑:`order_by(...).limit(1)` 随机选择一个系统 agent,不区分阶段。 新逻辑:按 `agent_type` 显式映射到三阶段。 ### 选模规则 **必需的 Agent Types:** - `INTENT_RECOGNITION` → 用于 intent_stage - `TASK_EXECUTION` → 用于 execution_stage - `RESULT_REPORTING` → 用于 organization_stage **查询逻辑:** ```python REQUIRED_TYPES = {"INTENT_RECOGNITION", "TASK_EXECUTION", "RESULT_REPORTING"} @dataclass(frozen=True) class StageModels: intent: SystemAgentCatalog execution: SystemAgentCatalog organization: SystemAgentCatalog def resolve_stage_models(rows: list[SystemAgentCatalog]) -> StageModels: by_type = {row.agent_type: row for row in rows} missing = REQUIRED_TYPES - set(by_type.keys()) if missing: raise ValueError(f"Missing required agent types: {missing}") return StageModels( intent=by_type["INTENT_RECOGNITION"], execution=by_type["TASK_EXECUTION"], organization=by_type["RESULT_REPORTING"], ) ``` **初始化数据约束:** - `system_agents` 表必须包含三种类型的记录 - 运行时启动时验证完整性 --- ## 人民币结算策略设计 ### 设计原则 1. **保留 LiteLLM 语义**:`completion_cost()` 始终返回 USD 2. **业务层映射**:根据用户国家(`profiles.settings.preferences.country`)决定落库货币 3. **默认人民币**:中国用户或无国家信息默认 CNY 4. **汇率配置**:USD/CNY 汇率通过环境变量配置 ### 货币来源 ``` UserAgentContext.settings.preferences.country ↓ resolve_billing_currency(country) ↓ CN → CNY US → USD 其他 → USD ``` ### 结算流程 ``` LiteLLM completion_cost() ↓ (USD) resolve_billing_cost(usd_cost, country) ↓ ├─ country="CN" or None → CNY (乘以汇率) └─ country="US" → USD (保持原值) ↓ messages.cost + messages.currency sessions.total_cost (同一货币) ``` ### 汇率配置 ```python # 环境变量 BILLING_USD_CNY_RATE=7.2 # 默认值 DEFAULT_USD_CNY_RATE = Decimal("7.2") ``` ### 结算模型 ```python @dataclass(frozen=True) class BillingCost: currency: str # "CNY" or "USD" cost: Decimal # 6位小数精度 def resolve_billing_cost( usd_cost: Decimal, country: str | None, usd_cny_rate: Decimal = DEFAULT_USD_CNY_RATE, ) -> BillingCost: currency = "CNY" if (country or "CN").upper() == "CN" else "USD" if currency == "CNY": cost = usd_cost * usd_cny_rate else: cost = usd_cost return BillingCost( currency=currency, cost=cost.quantize(Decimal("0.000001")) ) ``` ### 数据库落库 **messages 表:** - `cost`: NUMERIC(12,6) - 业务货币金额 - `currency`: VARCHAR(3) - "CNY" or "USD" **sessions 表:** - `total_cost`: NUMERIC(12,6) - 同一货币累计 **约束:** - 同一 session 内所有 messages 的 currency 必须一致 - sessions.total_cost 累加时保持货币一致 --- ## Session 状态一致性设计 ### 问题背景 旧逻辑: - `sessions.status` 与 `state_snapshot.status` 不同步 - 失败时状态不一致 - title 未自动赋值 ### 状态机 ``` pending (创建) ↓ running (开始执行) ↓ ├─ completed (成功) └─ failed (异常) ``` ### 状态同步规则 **创建时:** ```python session = AgentChatSession( user_id=user_uuid, status=AgentChatSessionStatus.PENDING, state_snapshot={ "status": "pending", "pending_tool_call_id": None, }, ) ``` **运行时:** ```python # 开始执行 session.status = AgentChatSessionStatus.RUNNING session.state_snapshot["status"] = "running" # 成功完成 session.status = AgentChatSessionStatus.COMPLETED session.state_snapshot["status"] = "completed" # 失败 session.status = AgentChatSessionStatus.FAILED session.state_snapshot["status"] = "failed" session.state_snapshot["error_id"] = error_id ``` ### 自动 Title 赋值 **规则:** - 首次运行时,如果 `session.title` 为空,使用 `user_input[:255]` 赋值 - 只在第一次运行时赋值,后续不覆盖 **实现:** ```python async def _set_title_if_empty(self, session_id: UUID, title: str) -> None: stmt = ( update(AgentChatSession) .where(AgentChatSession.id == session_id) .where(AgentChatSession.title.is_(None)) .values(title=title[:255]) ) await self.db.execute(stmt) ``` ### Repository 方法 ```python class SessionRepository: async def mark_running(self, session_id: UUID) -> None: ... async def mark_completed(self, session_id: UUID) -> None: ... async def mark_failed(self, session_id: UUID, error_id: str) -> None: ... ``` --- ## 全局 Prompt 构建设计 ### 分层结构 ``` 全局系统 Prompt ├─ 身份段(username/bio) ├─ 偏好段(language/timezone/country) └─ 阶段段(动态注入) ├─ intent stage prompt ├─ execution stage prompt └─ organization stage prompt ``` ### 构建函数 ```python def build_global_system_prompt(ctx: UserAgentContext) -> str: lines = [ "# User Identity", f"username: {ctx.username}", f"bio: {ctx.bio or 'N/A'}", "", "# User Preferences", f"interface_language: {ctx.settings.preferences.interface_language}", f"ai_language: {ctx.settings.preferences.ai_language}", f"timezone: {ctx.settings.preferences.timezone}", f"country: {ctx.settings.preferences.country}", "", "# Instructions", "Use the user's preferences to personalize responses.", "Respond in the user's preferred AI language.", "Consider the user's timezone for time-related queries.", ] return "\n".join(lines) ``` ### 阶段注入 每个阶段运行时,在全局 prompt 基础上追加阶段特定的指令: ```python def build_stage_prompt( base_prompt: str, stage: str, # "intent" | "execution" | "organization" ctx: UserAgentContext, ) -> str: stage_prompts = { "intent": "Analyze the user's intent and decide if direct response is possible.", "execution": "Execute the required tasks and tools to fulfill the user's request.", "organization": "Format the execution results into a user-friendly response.", } return f"{base_prompt}\n\n# Stage: {stage}\n{stage_prompts[stage]}" ``` --- ## 依赖关系图 ``` UserAgentContext (核心上下文) ↓ ├─ ProfileSettings (用户配置) │ └─ preferences.country → 人民币结算 │ ├─ build_global_system_prompt() (全局 Prompt) │ └─ 三阶段 Flow 使用 │ └─ resolve_stage_models() (选模逻辑) └─ 三阶段 Agent 配置 ``` --- ## 相关文档 - [Runtime Database Schema](../runtime/runtime-database.md) - [AG-UI Protocol](.opencode/skills/ag-ui/SKILL.md) - [CrewAI Framework](.opencode/skills/crewai/SKILL.md)