Skip to main content

5 posts tagged with "aigent"

View all tags

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

· 2 min read

在为客户构建 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。

Complete Guide to Google Analytics 4 in React SPA

· 3 min read

TL;DR

Key points for GA4 in React SPA: 1) Set send_page_view: false to prevent duplicate counts; 2) Use useLocation to track route changes and send pageviews manually; 3) Set user_id after login for cross-device tracking.

Problem

Using GA4 default configuration in React SPA causes:

  1. Duplicate page_view counts on initial load
  2. No page_view triggered on route changes
  3. Unable to track logged-in users across devices

Root Cause

GA4 automatically sends a page_view event when the script loads. But SPA route changes don't refresh the page, so GA4 can't detect URL changes. Also, User-ID must be set manually after login - default config can't identify users.

Solution

1. Disable Auto Page View

When loading GA4 in index.html, set 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. Create Analytics Component for Route Tracking

// 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,
}
// Add user_id for logged-in users
if (user?.id) {
params.user_id = user.id
}
window.gtag('config', 'G-XXXXXXXXXX', params)
}
}, [location, user?.id])

return null
}

3. Wrap Router Root

// 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: [
// your route config...
],
},
])

4. Set User-ID on Login (Optional Enhancement)

// 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) {
// Set GA4 User-ID
if (typeof window.gtag === 'function') {
window.gtag('config', 'G-XXXXXXXXXX', {
user_id: session.user.id
})
}
}
}
)
return () => subscription.unsubscribe()
}, [])
}

FAQ

Q: Why doesn't GA4 track route changes in React SPA?

A: GA4 only sends page_view on page load by default. SPA route changes don't refresh the page, so you need to manually call gtag('config', ...) to send pageviews.

Q: What is GA4 User-ID used for?

A: User-ID links user behavior across different devices, enabling cross-device analytics, user retention analysis, and other advanced features. You need to enable User-ID in GA4 admin settings.

Q: How to verify GA4 configuration is correct?

A: Use Chrome extension "Google Tag Assistant" or GA4 DebugView (requires debug_mode). Check if page_view events fire on each route change and if user_id is set correctly.

Fix Pydantic v2 ORM Mode model_config Override Error

· 2 min read

TL;DR

Pydantic v2 no longer supports class Config. Use model_config = ConfigDict(from_attributes=True) instead. If your model has a field named model_config, you must rename it to avoid conflict with the reserved attribute.

Problem Symptoms

Error 1: class Config Not Working

from pydantic import BaseModel

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

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

Error 2: model_config Field Conflict

class Agent(BaseModel):
id: str
model_config: dict # Business field storing LLM config

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

Your model has a business field called model_config (storing LLM configuration), which conflicts with Pydantic v2's reserved name.

Root Cause

1. Pydantic v2 Configuration Syntax Change

Pydantic v2 uses model_config as the configuration attribute name, no longer supporting nested 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 is a Reserved Name

model_config is a special attribute in Pydantic v2 and cannot be used as a business field name simultaneously.

Solution

1. Update ORM Mode Configuration

from pydantic import BaseModel, ConfigDict

class AgentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # New syntax

id: str
name: str

2. Rename Conflicting Field

Rename the business field model_config to llm_config (or any non-reserved name):

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

id: str
llm_config: dict # Renamed to avoid conflict

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

agent_id: str
llm_config: LlmConfig # Keep consistent with model

3. Database Migration (If Needed)

If the database column also needs renaming:

# 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: What did Pydantic v2 replace orm_mode with?

A: It's now from_attributes=True, and the configuration syntax changed from class Config to model_config = ConfigDict(...).

Q: Why is my model_config field causing errors?

A: model_config is a reserved attribute name in Pydantic v2 for configuring model behavior. If your business code has a field with the same name, you need to rename it.

Q: What other common ConfigDict options exist?

A: from_attributes (ORM mode), json_schema_extra (schema extension), str_strip_whitespace (auto strip whitespace), validate_assignment (validate on assignment).

Vite Path Alias Configuration - Why You Need Two Configs

· 2 min read

TL;DR

Vite path aliases require simultaneous configuration in both vite.config.ts and tsconfig.json—neither works alone. Vite handles bundler resolution, TypeScript handles type checking and IDE intellisense.

Problem Symptoms

Only Configured vite.config.ts

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

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

Build runs fine, but IDE shows errors:

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

Only Configured tsconfig.json

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

IDE is happy, but Vite build fails:

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

Root Cause

Two Configs, Two Responsibilities

Config FileOwnerPurpose
vite.config.tsVite/esbuildPath resolution during build
tsconfig.jsonTypeScriptType checking, IDE intellisense

Configuring only one:

  • Vite can build, but IDE shows red lines everywhere, no go-to-definition
  • IDE works, but vite dev / vite build can't find modules

Solution

Complete Configuration (Both Required)

// 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"]
}

Verify Configuration Works

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

// src/App.tsx - Should have go-to-definition, intellisense, and build correctly
import { api } from '@/services/api'

Multiple Aliases Example

// 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: Why does Vite path alias need two configurations?

A: Vite (based on esbuild/rollup) and TypeScript are independent tools. Vite handles module resolution during bundling, TypeScript handles compile-time type checking and IDE support. They don't share configuration.

Q: Still getting errors after configuration?

A: Restart IDE and Vite dev server. VSCode: Cmd+Shift+P → "TypeScript: Restart TS Server", Terminal: Ctrl+C to restart npm run dev.

Q: path.resolve __dirname error?

A: Make sure to import for ES Module: import path from 'path', or add "type": "module" in package.json. Or use import.meta.url instead of __dirname.

Implementing Cascade Select Dropdowns in React

· 3 min read

TL;DR

The key to cascade selection: when parent changes, reset child to a valid value. Use Record<string, Option[]> for type-safe data mapping, and update child state inside onValueChange callback.

Problem

When implementing Provider → Model cascade selection, after switching Provider:

// Before: provider = "openai", model = "gpt-4o"
// After: provider = "anthropic", model = "gpt-4o" ❌

// Model dropdown shows blank because "gpt-4o" is not in anthropic's model list
<Select value={model}> // value not in options, displays blank

Or when submitting the form, Model value is from the previous Provider, causing backend validation to fail.

Root Cause

In React controlled components, the value must exist in options. When Provider changes, Model's options list updates, but model state retains the old value. If the old value isn't in the new options, the Select component displays blank.

The key issue: only updated the options data, didn't sync the state value.

Solution

1. Define Data Structure

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

// Use Record type for mapping
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. Initialize State

const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat') // Must be valid for initial provider

3. Key: Reset Model When Provider Changes

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
// Core: reset model to first option of new provider
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

4. Complete Component Example

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 */}
<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 Select - dynamic options based on 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. Form Reset

Reset form when closing Dialog to avoid stale state:

const resetForm = () => {
setProvider('deepseek')
setModel('deepseek-chat') // Reset to default for provider
}

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

FAQ

Q: Why does my cascade select child dropdown show blank after parent changes?

A: In the parent's onValueChange callback, sync the child state to the first value of the new options list. In controlled components, value must exist in options.

Q: How to type cascade select data in TypeScript?

A: Use Record<string, Option[]> to map parent to children, e.g., Record<string, { value: string; label: string }[]>. This is type-safe and easy to extend.

Q: What happens when Select value doesn't match any option?

A: Most UI libraries (Radix, MUI, Ant Design) display blank or placeholder without errors. This is expected behavior for controlled components—ensure value is always a valid option.