跳到主要内容

5 篇博文 含有标签「aigent」

查看所有标签

修复 FastAPI SSE 客户端断开时的 CancelledError

· 阅读需 2 分钟

在为客户构建 AI 客服自动化系统时遇到此问题,记录根因与解法。

TL;DR

FastAPI 的 StreamingResponse 在客户端断开连接时会取消生成器任务,导致 asyncio.CancelledError。正确做法是在生成器中捕获该异常并 re-raise,否则会导致异常日志污染和资源泄漏。

问题现象

使用 SSE(Server-Sent Events)实现流式对话时,客户端断开连接后,服务端日志出现大量异常:

ERROR:    Exception in ASGI application
...
asyncio.CancelledError

代码原本写法:

async def event_stream():
async for event in engine.execute(body.message):
yield event

return StreamingResponse(event_stream(), media_type="text/event-stream")

根因

FastAPI/Starlette 的 StreamingResponse 在客户端断开时,会取消正在执行的生成器任务。Python 的 async for 循环被取消时会抛出 asyncio.CancelledError

如果不处理这个异常,它会向上传播,被 ASGI 服务器捕获并记录为错误日志。更严重的是,生成器内的资源(如数据库连接、HTTP 客户端)可能无法正确释放。

解决方案

在生成器内部捕获 CancelledError,记录日志后 必须 re-raise

import asyncio
import logging

logger = logging.getLogger(__name__)

async def event_stream():
try:
async for event in engine.execute(body.message):
yield event
except asyncio.CancelledError:
# 客户端断开连接,正常行为
logger.info("Client disconnected")
raise # 必须 re-raise 以正确终止生成器

return StreamingResponse(event_stream(), media_type="text/event-stream")

为什么必须 re-raise?

CancelledError 是 Python 取消协程的标准机制。捕获后如果不 re-raise:

  1. 生成器不会正确终止
  2. StreamingResponse 认为响应正常完成
  3. 可能导致资源泄漏

FAQ

Q: FastAPI SSE 客户端断开后为什么报 CancelledError?

A: 这是 Python asyncio 的设计行为。客户端断开时,Starlette 取消生成器任务,触发 CancelledError。正确处理方式是捕获并 re-raise。

Q: 捕获 CancelledError 后不 re-raise 会怎样?

A: 生成器无法正确终止,可能导致数据库连接、HTTP 客户端等资源泄漏。同时 StreamingResponse 会误认为响应正常完成。

Q: 如何区分正常断开和异常断开?

A: CancelledError 本身就是正常断开的信号。如果需要在断开时执行清理逻辑(如更新状态),在 except 块中处理后再 re-raise。

React SPA 集成 Google Analytics 4 完整指南

· 阅读需 3 分钟

TL;DR

React SPA 集成 GA4 的关键点:1) 禁用 send_page_view: false 避免重复追踪;2) 用 useLocation 监听路由变化手动发送 pageview;3) 登录后设置 user_id 实现跨设备追踪。

问题现象

在 React SPA 中直接使用 GA4 默认配置会导致:

  1. 首次加载时 page_view 重复计数
  2. 路由切换时不触发 page_view
  3. 无法追踪登录用户的跨设备行为

根因

GA4 默认在脚本加载时自动发送一次 page_view 事件。但 SPA 的路由切换不刷新页面,GA4 无法感知 URL 变化。同时,User-ID 需要在用户登录后手动设置,默认配置无法关联用户身份。

解决方案

1. 禁用自动 page_view

index.html 中加载 GA4 时,设置 send_page_view: false

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
</script>

2. 创建 Analytics 组件追踪路由

// src/components/Analytics.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetIdOrDate: string | Date,
params?: Record<string, unknown>
) => void
}
}

export function Analytics() {
const location = useLocation()
const user = useAuthStore((state) => state.user)

useEffect(() => {
if (typeof window.gtag === 'function') {
const params: Record<string, unknown> = {
page_path: location.pathname + location.search,
}
// 已登录用户添加 user_id
if (user?.id) {
params.user_id = user.id
}
window.gtag('config', 'G-XXXXXXXXXX', params)
}
}, [location, user?.id])

return null
}

3. 包裹路由根节点

// src/app/routes.tsx
import { Outlet } from 'react-router-dom'
import { Analytics } from '@/components/Analytics'

function RootLayout() {
return (
<>
<Analytics />
<Outlet />
</>
)
}

export const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
// 你的路由配置...
],
},
])

4. 登录时设置 User-ID(可选增强)

// src/hooks/useAuth.ts
import { supabase } from '@/services/supabase'

export function useAuth() {
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN' && session) {
// 设置 GA4 User-ID
if (typeof window.gtag === 'function') {
window.gtag('config', 'G-XXXXXXXXXX', {
user_id: session.user.id
})
}
}
}
)
return () => subscription.unsubscribe()
}, [])
}

FAQ

Q: React SPA 中 GA4 为什么不追踪路由变化?

A: GA4 默认只在页面加载时发送 page_view。SPA 路由切换不刷新页面,需要手动调用 gtag('config', ...) 发送 pageview。

Q: GA4 User-ID 有什么用?

A: User-ID 可以关联同一用户在不同设备上的行为,用于跨设备分析、用户留存分析等高级功能。需要在 GA4 后台开启 User-ID 功能视图。

Q: 如何验证 GA4 配置是否正确?

A: 使用 Chrome 扩展 "Google Tag Assistant" 或 GA4 DebugView(需开启 debug_mode)。检查每次路由切换是否触发 page_view 事件,以及 user_id 是否正确设置。

解决 Pydantic v2 ORM mode 报错 model_config 被覆盖

· 阅读需 2 分钟

TL;DR

Pydantic v2 不再支持 class Config,需要用 model_config = ConfigDict(from_attributes=True)。如果你的模型有 model_config 字段,必须重命名避免与保留字冲突。

问题现象

报错 1:class Config 不生效

from pydantic import BaseModel

class AgentResponse(BaseModel):
id: str
name: str

class Config:
orm_mode = True # v1 写法
PydanticUserError: `orm_mode` is not a valid config option. Did you mean `from_attributes`?

报错 2:model_config 字段冲突

class Agent(BaseModel):
id: str
model_config: dict # 业务字段,存储 LLM 配置

model_config = ConfigDict(from_attributes=True)
# TypeError: 'dict' object is not callable

模型中有个业务字段叫 model_config(存储 LLM 配置),与 Pydantic v2 保留字冲突。

根因

1. Pydantic v2 配置语法变化

Pydantic v2 使用 model_config 作为配置属性名,不再支持嵌套的 class Config

Pydantic v1Pydantic v2
class Config: orm_mode = Truemodel_config = ConfigDict(from_attributes=True)
class Config: schema_extra = {...}model_config = ConfigDict(json_schema_extra={...})

2. model_config 是保留字

model_config 在 Pydantic v2 中是特殊属性,不能同时作为业务字段名使用。

解决方案

1. 更新 ORM mode 配置

from pydantic import BaseModel, ConfigDict

class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # 新写法

id: str
name: str

2. 重命名冲突字段

将业务字段 model_config 改为 llm_config(或任意非保留名):

# models/agent.py
class Agent(BaseModel):
__tablename__ = "agent_agents"

id: str
llm_config: dict # 改名,避免冲突

# schemas/agent.py
class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)

agent_id: str
llm_config: LlmConfig # 与模型保持一致

3. 数据库迁移(如需要)

如果数据库字段也要改:

# alembic/versions/xxx_rename_model_config.py
def upgrade():
op.alter_column('agent_agents', 'model_config', new_column_name='llm_config')

def downgrade():
op.alter_column('agent_agents', 'llm_config', new_column_name='model_config')

FAQ

Q: Pydantic v2 的 orm_mode 改成什么了?

A: 改为 from_attributes=True,配置方式从 class Config 变成 model_config = ConfigDict(...)

Q: 为什么 model_config 字段报错?

A: model_config 是 Pydantic v2 的保留属性名,用于配置模型行为。如果业务代码中有同名字段,需要重命名。

Q: ConfigDict 还有哪些常用选项?

A: from_attributes (ORM mode)、json_schema_extra (schema 扩展)、str_strip_whitespace (自动去空格)、validate_assignment (赋值时验证)。

Vite 路径别名配置:改了两处才生效

· 阅读需 2 分钟

TL;DR

Vite 路径别名需要同时配置 vite.config.tstsconfig.json,缺一不可:Vite 负责打包时解析,TypeScript 负责类型检查和 IDE 提示。

问题现象

只配了 vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
import path from 'path'

export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})

打包运行正常,但 IDE 报错:

Cannot find module '@/components/Button' or its corresponding type declarations.

只配了 tsconfig.json

// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

IDE 不报错了,但 Vite 构建时报错:

[vite] Internal server error: Failed to resolve import "@/services/api"

根因

两套配置,两个职责

配置文件负责方作用
vite.config.tsVite/esbuild构建时解析路径
tsconfig.jsonTypeScript类型检查、IDE 智能提示

只配置一处:

  • Vite 能打包,但 IDE 满屏红线,无法跳转
  • IDE 正常,但 vite dev / vite build 找不到模块

解决方案

完整配置(两处都要)

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

验证配置生效

// src/services/api.ts
export const api = { ... }

// src/App.tsx - 应该能跳转、有提示、构建正常
import { api } from '@/services/api'

多个别名示例

// vite.config.ts
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
}
}

// tsconfig.json
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"]
}

FAQ

Q: 为什么 Vite 路径别名要配两次?

A: Vite(基于 esbuild/rollup)和 TypeScript 是独立工具。Vite 负责打包时的模块解析,TypeScript 负责编译时类型检查和 IDE 支持。两者不共享配置。

Q: 配置后还是报错怎么办?

A: 重启 IDE 和 Vite dev server。VSCode 按 Cmd+Shift+P → "TypeScript: Restart TS Server",终端 Ctrl+C 重启 npm run dev

Q: path.resolve 的 __dirname 报错?

A: 确保是 ES Module 时导入:import path from 'path',或在 package.json 加 "type": "module"。或用 import.meta.url 替代 __dirname

实现 React 级联选择下拉框

· 阅读需 4 分钟

TL;DR

级联选择的核心是:父级变化时,必须重置子级为有效值。使用 Record<string, Option[]> 类型映射数据,在 onValueChange 回调中同步更新子级状态。

问题现象

实现 Provider → Model 级联选择时,切换 Provider 后:

// 切换前:provider = "openai", model = "gpt-4o"
// 切换后:provider = "anthropic", model = "gpt-4o" ❌

// Model 下拉框显示为空,因为 "gpt-4o" 不在 anthropic 的模型列表中
<Select value={model}> // model 值不在 options 中,显示空白

或者提交表单时,Model 值是上一个 Provider 的模型,导致后端验证失败。

根因

React 受控组件的 value 必须存在于 options 中。当 Provider 变化时,Model 的 options 列表更新了,但 model state 仍保留旧值。如果旧值不在新的 options 中,Select 组件会显示为空。

关键问题:只更新了 options 数据,没有同步更新 state 值

解决方案

1. 定义数据结构

const AVAILABLE_PROVIDERS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
]

// 使用 Record 类型建立映射关系
const AVAILABLE_MODELS: Record<string, { value: string; label: string }[]> = {
deepseek: [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
],
openai: [
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
],
anthropic: [
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
],
}

2. State 初始化

const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat') // 初始值必须是 provider 对应的第一个模型

3. 关键:Provider 变化时重置 Model

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
// 核心:重置 model 到新 provider 的第一个选项
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

4. 完整组件示例

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

function CascadeSelect() {
const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat')

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

return (
<>
{/* Provider 选择 */}
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Model 选择 - 动态根据 provider 显示选项 */}
<Select value={model} onValueChange={(v) => v && setModel(v)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{(AVAILABLE_MODELS[provider] || []).map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)
}

5. 表单重置

关闭 Dialog 时重置表单,避免下次打开时保留旧状态:

const resetForm = () => {
setProvider('deepseek')
setModel('deepseek-chat') // 重置为 provider 对应的默认值
}

const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm()
}
onOpenChange(newOpen)
}

FAQ

Q: React 级联选择下拉框切换后子级显示为空怎么办?

A: 在父级 onValueChange 回调中,同步更新子级 state 为新选项列表的第一个值。受控组件的 value 必须存在于 options 中。

Q: 如何用 TypeScript 定义级联选择的数据类型?

A: 使用 Record<string, Option[]> 类型建立父级到子级的映射,例如 Record<string, { value: string; label: string }[]>,类型安全且易于扩展。

Q: Select 组件的 value 和 options 不匹配会怎样?

A: 大多数 UI 库(Radix、MUI、Ant Design)会显示空白或 placeholder,不会报错。这是受控组件的预期行为——确保 value 始终是有效的选项值。