我们要添加一个新功能:群体讨论(discussionChat)。
这应该很简单。我们已经有 interviewChat 了——一对一访谈,用户和 AI 模拟的 persona 深度对话。群体讨论只是从 1对1 变成 1对多:3-8个 persona 同时参与,观察他们的观点碰撞。
理论上,只需要:
但现实是:我们需要改动 12 个文件。
更糟的是,我们发现了这个:
三个几乎完全相同的 Agent wrapper。 每添加一个功能,都要在三个地方复制粘贴。 每修一个 bug,都要改三次。
那一刻我们意识到:有些东西从根本上错了。
不是代码不够优雅。 不是没有抽象。 而是我们在用传统软件工程的思维构建 AI Agent 系统。
这篇文章记录我们如何走出这个困境——通过三次架构演进,重新思考 AI Agent 应该如何构建。
在动手重构前,我们停下来问了一个根本问题:
AI Agent 和传统软件有什么本质区别?
传统软件基于状态机:
这个模型的核心假设:
这在传统软件中很好。但在 AI Agent 中?
LLM 不是这样工作的:
这里的"状态"在哪里?
state 字段AI 从对话历史推断:
这是完全不同的范式。
从这个观察出发,我们得出了三个洞察,它们塑造了我们的架构演进。
传统做法:维护显式状态
AI-native 做法:从对话推断状态
为什么对话优于状态机?
人类做决策:
AI Agent 也应该如此:
为什么分离?
面对"AI 健忘"问题,我们可以:
方案 A:Vector DB + Semantic Search
方案 B:Markdown 文件 + 全文加载
我们选择了方案 B。
为什么?
从这三个洞察,我们提炼出架构的核心原则:
1. Messages as Source of Truth
2. Configuration over Code
3. AI as State Manager
4. Simple, Transparent, Controllable
v2.2.0 - 2025-12-27
最初,研究数据散落在三个地方:
生成报告时,需要从三个地方拼接:
问题:
interviews.conclusion 和消息中的访谈内容可能不同步discussionChat 需要新表、新工具、新查询更麻烦的是工具输出不一致:
Agent 无法统一处理,导致代码复杂。
核心思路:所有研究内容都输出到消息流,数据库只存派生状态。
关键变化:
删除 5 个专用保存工具
saveInterview, saveDiscussion, saveScoutTask, ...统一工具输出格式
plainText按需生成 studyLog
从第一性原理推导:
对话即上下文
LLM 擅长提取
Event Sourcing 的影子
与其他方案对比:
| 方案 | 优点 | 缺点 | 为何未选 |
|---|---|---|---|
| Messages as source | 数据一致、易扩展 | 需要额外 LLM 调用生成 studyLog | ✅ 我们的选择 |
| 传统状态管理 | 精确控制 | 状态同步复杂、难追溯 | 不适合 LLM 的非确定性 |
| 完全移除 DB | 极简 | 前端查询困难、历史数据难管理 | 需要结构化展示 |
| Event Sourcing | 完整历史、可重放 | 工程复杂度高 | 对当前规模过度设计 |
代码简化:
开发效率:
Before:
After:
成本权衡:
✅ 收益:
❌ 代价:
✅ 缓解:
v2.3.0 - 2026-01-06
完成消息驱动架构后,添加新功能变简单了。但用户体验还不够好。
用户创建研究时常说:
"想了解年轻人对咖啡的偏好"
这不够具体:
传统做法:AI 多轮追问
问题:
添加功能虽然简化了,但我们发现了更大的技术债:
三个几乎完全相同的 Agent wrapper,总计 1,211 行。
代码重复主要在:
每次添加新功能(如 webhook 集成),都要改三个地方。
我们的解决方案包含两个部分:
一个独立的 Agent,专门负责意图澄清:
工作流程:
关键设计:
将三个重复的 Agent wrapper 合并成一个通用执行器:
Agent 路由:
每个 Agent 只需定义配置:
推理-执行分离的理由:
符合认知模型
单一职责
Messages 作为协议
统一执行器的理由:
Extract, Don't Rebuild
Configuration over Inheritance
Plugin-based Lifecycle
customPrepareStep:动态工具控制customOnStepFinish:自定义后处理与其他方案对比:
| 方案 | 优点 | 缺点 | 为何未选 |
|---|---|---|---|
| Plan Mode + baseAgentRequest | 删除重复代码、推理执行分离 | 多一层抽象 | ✅ 我们的选择 |
| 继续复制粘贴 | 简单直接 | 技术债累积、难以维护 | 长期不可持续 |
| 完全通用 Agent | 代码最少 | 牺牲专业性和控制力 | 无法处理业务差异 |
| 微服务拆分 | 独立部署 | 过度设计、增加运维复杂度 | 当前规模不需要 |
代码复杂度:
但更重要的是:
开发效率:
Before:
After:
用户体验:
Before:
After:
意图澄清:3-5 轮对话 → 1 次确认
v2.3.0 - 2026-01-08
有了意图澄清和统一架构,研究流程已经很顺畅。但长期使用中,用户反馈了一个问题:
"为什么每次对话,AI 都要重新问我从事什么行业?"
AI 不记得用户。每次对话都像第一次见面:
用户感觉 AI 很"健忘",体验不够个性化。
根本原因:
LLM 是无状态的。每次对话:
虽然我们有历史对话存在 DB,但:
我们需要一个持久化的记忆系统。但如何设计?
受启发于 Anthropic 的 CLAUDE.md 方法:
我们采用类似方案,但增加了自动更新机制。
双层架构:
核心记忆(core)
工作记忆(working)
两阶段更新:
Memory Update Agent(Haiku 4.5):
Memory Reorganize Agent(Sonnet 4.5):
为什么选择 Markdown 而非 Vector DB?
Context Window 够大
简单透明
避免过早优化
与主流方案对比:
| 方案 | 存储 | 控制 | 检索 | atypica 选择理由 |
|---|---|---|---|---|
| Anthropic (CLAUDE.md) | File-based | 用户驱动 | 全文加载 | ✅ 简单透明,大 context 下有效 |
| OpenAI | Vector DB(推测) | AI + 用户确认 | 语义检索 | ❌ 黑盒,用户控制力弱 |
| Mem0 | Vector + Graph + KV | AI 驱动 | 混合检索 | ❌ 过度工程化,维护成本高 |
| MemGPT | OS-inspired 分层 | AI 自管理 | 分层检索 | ❌ 概念复杂,实用性未验证 |
我们选择了 Anthropic 的简单方案,原因:
用户体验:
Before:
After:
系统成本:
响应时间:
成本低、响应快,完全可接受。
现在让我们后退一步,看看 atypica 的架构与主流 AI Agent 框架的区别。
| atypica | LangChain | 核心区别 |
|---|---|---|
| Messages as source | ConversationBufferMemory | 我们相信对话历史就是最好的状态 |
| 按需生成 studyLog | 预先计算 summary | 避免同步问题,失败时可追溯 |
| 数据库存派生状态 | 数据库存核心状态 | 类似 Event Sourcing |
为什么不同?
LangChain 的设计受传统软件影响,认为"状态应该显式存储和管理"。
我们认为,对于 LLM:
| atypica | LangGraph | 核心区别 |
|---|---|---|
| 配置驱动 | 图驱动 | 我们用配置表达差异,代码表达共性 |
| 单一执行器 | 节点编排 | 避免过度抽象,够用即可 |
| Messages 作为协议 | 显式节点通信 | 松耦合但不失上下文 |
为什么不同?
LangGraph 追求通用性,用图编排表达任意复杂流程。
我们认为,对于我们的场景:
| atypica | Mem0 | 核心区别 |
|---|---|---|
| Markdown 文件 | Vector + Graph + KV | 我们选择简单透明,而非精确复杂 |
| 全文加载 | 语义检索 | context window 足够大时,全文更好 |
| 用户可编辑 | AI 黑盒 | 用户信任来自透明 |
为什么不同?
Mem0 追求精确检索,用多种数据库混合。
我们认为,对于个人助手:
atypica 的选择:
主流框架的选择:
谁对谁错?
都没错。只是:
三次演进带来的具体影响:
| 任务 | Before | After | 提升 |
|---|---|---|---|
| 添加新研究方式 | 12 文件,2-3 天 | 3 文件,2-3 小时 | 10x |
| 添加新能力(MCP) | 修改 3 处,1 天 | 修改 1 处,2 小时 | 4x |
| 修复 bug | 改 3 个 Agent | 改 1 个 base | 3x |
成本和性能影响可忽略不计。
三次演进中,我们学到了什么?
1. 渐进式重构,不做大爆炸
我们没有一次性重写整个系统。三次演进,每一步都:
analyst.studySummary 字段)这让我们能快速验证想法,降低风险。
2. 从真实痛点出发
不追求架构完美,而是:
discussionChat 太复杂让问题驱动设计,而非设计驱动问题。
3. 拥抱 LLM 的特性
不把 LLM 当作传统软件:
适应 LLM 的能力边界,而非对抗它。
1. 抽象层的学习曲线
baseAgentRequest 需要理解才能修改:
customPrepareStep 和 customOnStepFinish 的时机但:清晰的接口和文档降低了门槛。
2. 按需生成的成本
studyLog 生成需要 LLM 调用(~$0.002/次)。
但:
3. 简单方案的局限
Markdown 记忆不适合:
但:
1. 类型安全带来的信心
重构时,编译器就能发现 99% 的问题。
2. 配置驱动的灵活性
添加 webhook 集成只需:
所有 Agent 自动获得新能力,无需修改配置。
3. Messages 作为协议的威力
Plan Mode 和 Study Agent 通过 messages 通信:
这是我们没想到的好处。
三次演进让 atypica 更接近通用智能体。但还有更多要做。
1. Skills Library
2. Multi-Agent Collaboration
3. 向 GEA 演进
4. Self-Improving Agent
无论如何演进,我们坚持:
构建 AI Agent 系统,不是传统软件工程的简单延伸。
我们需要重新思考:
atypica 的三次演进,本质上是三次认知升级:
从数据库思维 → 数据流思维
从代码复用 → 配置驱动
从无状态 → 记忆增强
这些选择可能不是最"先进"的。
但它们是:
而这,或许才是构建可靠 AI 系统的关键。
prisma/schema.prisma # 新建 Discussion 表src/ai/tools/discussionChat.ts # 新工具src/ai/tools/saveDiscussion.ts # 保存工具src/app/(study)/agents/studyAgent.ts # 添加工具到 Agentsrc/app/(study)/agents/fastInsightAgent.ts # 再添加一次src/app/(study)/agents/productRnDAgent.ts # 再添加一次... 还有 6 个文件// studyAgentRequest.ts (493 行)export async function studyAgentRequest(context) { const result = await streamText({ model: llm("claude-sonnet-4"), system: studySystem(), messages, tools: { webSearch, interview, scoutTask, saveAnalyst, generateReport // ... 15 个工具 }, onStepFinish: async (step) => { // 保存消息 // 跟踪 token // 发送通知 // ... 120 行逻辑 } });}// fastInsightAgentRequest.ts (416 行)// 95% 相同的代码// productRnDAgentRequest.ts (302 行)// 95% 相同的代码class ResearchSession { state: 'IDLE' | 'PLANNING' | 'RESEARCHING' | 'REPORTING'; data: { interviews: Interview[]; findings: Finding[]; reports: Report[]; }; transition(event: Event) { switch (this.state) { case 'IDLE': if (event.type === 'START') this.state = 'PLANNING'; break; case 'PLANNING': if (event.type === 'PLAN_COMPLETE') this.state = 'RESEARCHING'; break; // ... 更多状态转换 } }}const messages = [ { role: 'user', content: '想了解年轻人对咖啡的偏好' }, { role: 'assistant', content: '我可以帮你做一个用户研究...' }, { role: 'assistant', toolCalls: [{ name: 'scoutTask', args: {...} }] }, { role: 'tool', content: '观察到了 5 个用户群体...' }, { role: 'assistant', content: '基于观察,我建议访谈 18-25 岁咖啡爱好者...' }, { role: 'assistant', toolCalls: [{ name: 'interviewChat', args: {...} }] }, // ...];// ❌ 传统:显式状态管理interface ResearchState { stage: 'planning' | 'researching' | 'reporting'; completedInterviews: number; pendingTasks: Task[];}// 需要同步:状态和对话历史可能不一致// ✅ AI-native:对话即状态const messages = [...conversationHistory];// AI 自己从历史推断状态,无需显式同步const result = await streamText({ messages, // AI 知道该做什么});// Plan Mode:理解意图"用户说:想了解年轻人对咖啡的偏好" → 分析:需要定性研究 → 决策:用群体讨论方法 → 输出:完整的研究计划// Study Agent:执行计划"收到研究计划" → 调用 discussionChat → 分析讨论结果 → 生成洞察报告// 精确匹配相关记忆const query_embedding = await embed(user_message);const relevant_memories = await vectorDB.search(query_embedding, top_k=5);// 简单透明const memory = await readFile(`memories/${userId}.md`);const messages = [ { role: 'user', content: `<UserMemory>\n${memory}\n</UserMemory>` }, ...conversationMessages];// 地方 1:analyst 表const analyst = await prisma.analyst.findUnique({ where: { id }});console.log(analyst.studySummary); // "研究总结..."// 地方 2:interviews 表const interviews = await prisma.interview.findMany({ where: { analystId: id }});console.log(interviews.map(i => i.conclusion)); // ["访谈1结论", "访谈2结论"]// 地方 3:messages 表const messages = await prisma.chatMessage.findMany({ where: { userChatId }});// webSearch 结果在这里async function generateReport(analystId) { const analyst = await prisma.analyst.findUnique({ where: { id: analystId }, include: { interviews: true } // JOIN! }); const messages = await prisma.chatMessage.findMany({ where: { userChatId: analyst.studyUserChatId } }); // 拼接数据 const reportData = { summary: analyst.studySummary, // 来自 analyst 表 interviewInsights: analyst.interviews.map(...), // 来自 interviews 表 webResearch: extractFromMessages(messages) // 来自 messages 表 };}// interviewChat:内容在 DB,返回引用{ toolName: 'interviewChat', output: { interviewId: 123 } // 需要再查询 DB}// scoutTaskChat:内容在返回值{ toolName: 'scoutTaskChat', output: { plainText: "观察结果...", // 直接返回内容 insights: [...] }}// ✅ 新架构:统一输出格式interface ResearchToolResult { plainText: string; // 人类可读的总结,必需 [key: string]: any; // 可选的结构化数据}// interviewChat 也返回 plainText{ toolName: 'interviewChat', output: { plainText: "访谈总结:用户张三表示...", // ← 完整内容在这里 interviewId: 123 // 可选:DB 引用 }}// 不预先保存,用时再生成if (!analyst.studyLog) { const messages = await loadMessages(studyUserChatId); const studyLog = await generateStudyLog(messages); // ← 从消息生成 await prisma.analyst.update({ where: { id }, data: { studyLog } });}删除文件:- src/ai/tools/saveInterview.ts- src/ai/tools/saveDiscussion.ts- src/ai/tools/saveScoutTask.ts- src/ai/tools/savePersona.ts- src/ai/tools/saveWebSearch.ts简化文件(28 个):- Agent 配置不再需要保存工具- generateReport 不需要多表 JOIN添加 discussionChat:1. 创建 Discussion 表2. 写 discussionChat 工具3. 写 saveDiscussion 工具4. 在 3 个 Agent 中添加这两个工具5. 写 discussion 查询逻辑6. 修改 generateReport 查询总计:12 个文件,2-3 天添加 discussionChat:1. 写 discussionChat 工具(返回 plainText)2. 在 Agent 配置中添加工具3. generateReport 自动支持(从消息读取)总计:3 个文件,2-3 小时AI: "你想研究哪个年龄段?"User: "18-25岁吧"AI: "你想用什么方法?访谈还是问卷?"User: "访谈"AI: "需要多少人?"User: "10个左右"$ wc -l src/app/(study)/agents/*AgentRequest.ts493 studyAgentRequest.ts416 fastInsightAgentRequest.ts302 productRnDAgentRequest.ts// src/app/(study)/agents/configs/planModeAgentConfig.tsexport async function createPlanModeAgentConfig() { return { model: "claude-sonnet-4-5", systemPrompt: planModeSystem({ locale }), tools: { requestInteraction, // 和用户交互 makeStudyPlan, // 展示完整计划,一键确认 }, maxSteps: 5, // 最多 5 步完成意图澄清 };}// src/app/(study)/agents/baseAgentRequest.ts (577 行)interface AgentRequestConfig<TOOLS extends ToolSet> { model: LLMModelName; systemPrompt: string; tools: TOOLS; maxSteps?: number; specialHandlers?: { // 动态控制哪些工具可用 customPrepareStep?: (options) => { messages, activeTools?: (keyof TOOLS)[] }; // 自定义后处理逻辑 customOnStepFinish?: (step, context) => Promise<void>; };}async function executeBaseAgentRequest<TOOLS>( baseContext: BaseAgentContext, config: AgentRequestConfig<TOOLS>, streamWriter: UIMessageStreamWriter) { // Phase 1: Initialization // Phase 2: Prepare Messages // Phase 3: Universal Attachment Processing // Phase 4: Universal MCP and Team System Prompt // Phase 5: Load Memory and Inject into Context // Phase 6: Main Streaming Loop // Phase 7: Universal Notifications}// src/app/(study)/api/chat/route.tsif (!analyst.kind) { // Plan Mode - 意图澄清 const config = await createPlanModeAgentConfig(agentContext); await executeBaseAgentRequest(agentContext, config, streamWriter);} else if (analyst.kind === AnalystKind.productRnD) { // Product R&D Agent const config = await createProductRnDAgentConfig(agentContext); await executeBaseAgentRequest(agentContext, config, streamWriter);} else { // Study Agent(综合研究、快速洞察、测试、创意等) const config = await createStudyAgentConfig(agentContext); await executeBaseAgentRequest(agentContext, config, streamWriter);}// src/app/(study)/agents/configs/studyAgentConfig.tsexport async function createStudyAgentConfig(params) { return { model: "claude-sonnet-4", systemPrompt: studySystem({ locale }), tools: buildStudyTools(params), // ← 这个 Agent 需要的工具 specialHandlers: { // 自定义工具控制 customPrepareStep: async ({ messages }) => { const toolUseCount = calculateToolUsage(messages); let activeTools = undefined; // 报告生成后,限制可用工具 if ((toolUseCount[ToolName.generateReport] ?? 0) > 0) { activeTools = [ ToolName.generateReport, ToolName.reasoningThinking, ToolName.toolCallError, ]; } return { messages, activeTools }; }, // 自定义后处理 customOnStepFinish: async (step) => { // 保存研究意图后,自动生成标题 const saveAnalystTool = findTool(step, ToolName.saveAnalyst); if (saveAnalystTool) { await generateChatTitle(studyUserChatId); } }, }, };}删除:- studyAgentRequest.ts (493 行)- fastInsightAgentRequest.ts (416 行)- productRnDAgentRequest.ts (302 行)合计:-1,211 行新增:+ baseAgentRequest.ts (577 行)+ planModeAgentConfig.ts (120 行)+ studyAgentConfig.ts (180 行)+ productRnDAgentConfig.ts (80 行)合计:+957 行净减少:-254 行添加 MCP 集成:1. 修改 studyAgentRequest.ts2. 修改 fastInsightAgentRequest.ts3. 修改 productRnDAgentRequest.ts4. 测试三个 Agent时间:2-3 天添加 MCP 集成:1. 修改 baseAgentRequest.ts2. 所有 Agent 自动获得新能力时间:2-3 小时用户:"想了解年轻人对咖啡的偏好"AI:"你想研究哪个年龄段?"用户:"18-25 岁"AI:"你想用什么方法?"用户:"访谈吧"AI:"需要多少人?"...(3-5 轮对话)用户:"想了解年轻人对咖啡的偏好"AI 展示完整计划:┌─────────────────────────────────────┐│ 【研究计划】 ││ 目标:理解 18-25 岁年轻人咖啡偏好 ││ 方法:群体讨论(5-8 人) ││ 预计时长:40 分钟 ││ 输出:消费者洞察报告 ││ ││ [确认开始] [修改计划] │└─────────────────────────────────────┘const result = await streamText({ messages: currentConversation, // ← 只有当前对话 // 没有历史对话的上下文});model Memory { id Int @id @default(autoincrement()) userId Int? // 用户级记忆 teamId Int? // 团队级记忆 version Int // 版本管理 // 双层架构 core String @default("") @db.Text // 核心记忆(Markdown) working Json @default("[]") // 工作记忆(JSON,待整合) changeNotes String @db.Text // 本次更新说明 @@unique([userId, version]) @@index([userId, version(sort: Desc)])}# 用户信息- 行业:消费品行业产品经理- 关注:年轻消费者偏好、新兴趋势# 研究风格- 偏好定性研究(访谈、讨论)- 重视真实用户声音,而非数据统计[ { "info": "用户最近关注咖啡赛道", "source": "chat_123" }, { "info": "偏好群体讨论方法", "source": "chat_124" }]// src/app/(memory)/actions.tsasync function updateMemory({ userId, conversationContext }) { let memory = await loadLatestMemory(userId); // Step 1: 超过阈值时重组(Claude Sonnet 4.5) if (memory.core.length > 8000 || memory.working.length > 20) { memory = await reorganizeMemory(memory, conversationContext); } // Step 2: 提取新信息(Claude Haiku 4.5) const newInfo = await extractMemoryUpdate(memory.core, conversationContext); if (newInfo) { // Step 3: 插入新信息到指定位置 await insertMemoryInfo(memory, newInfo); }}// src/app/(study)/agents/baseAgentRequest.ts// Phase 5: Load Memoryconst memory = await loadUserMemory(userId);if (memory?.core) { // 注入到对话开头 modelMessages = [ { role: 'user', content: `<UserMemory>\n${memory.core}\n</UserMemory>` }, ...modelMessages ];}// Phase 6: Streamingconst result = await streamText({ messages: modelMessages, // ← 包含用户记忆 // ...});// Phase 7: 非阻塞更新记忆waitUntil( updateMemory({ userId, conversationContext: messages }));第 1 次对话:User: "想做个咖啡研究"AI: "你从事什么行业?"User: "消费品行业"AI: "你关注哪些维度?"...第 2 次对话(一周后):User: "想做个茶饮研究"AI: "你从事什么行业?" # ← 又问了一遍第 1 次对话:User: "想做个咖啡研究"AI: "你从事什么行业?"User: "消费品行业产品经理"# AI 记住了第 2 次对话(一周后):User: "想做个茶饮研究"AI: "基于你作为消费品行业产品经理的背景,我建议..." # ← 记住了!Memory Update(每次对话):- Model: Claude Haiku 4.5- Tokens: ~5K- Cost: ~$0.001Memory Reorganize(每 20 次对话):- Model: Claude Sonnet 4.5- Tokens: ~15K- Cost: ~$0.02平均成本:~$0.002/次对话Memory 加载:+50ms(非阻塞)Memory 更新:后台运行,不影响响应重复代码:Before: 1,211 行(三个 Agent wrapper)After: 0 行减少:100%总代码行数:Before: 1,211 行(重复)+ 其他After: 577 行(base)+ 380 行(configs)= 957 行净减少:254 行(21%)Cyclomatic Complexity(代码复杂度指标):Before: avg 12.3After: avg 6.7降低:45%Token 消耗(有 prompt cache):- studyLog 生成: ~2K tokens (~$0.002)- Memory 更新: ~5K tokens (~$0.005)- 平均每次对话:+$0.007响应时间:- Memory 加载: +50ms(非阻塞)- Plan Mode: +2s(一次性)- studyLog 生成:后台,不影响响应意图澄清:Before: 平均 3.2 轮对话After: 1 次展示计划 + 1 次确认提升:3x 效率AI "记忆":Before: 每次对话问重复问题After: 自动加载用户偏好提升:个性化体验研究启动时间:Before: ~5 分钟(多轮澄清)After: ~1 分钟(一键确认)提升:5x 效率// 完全类型安全的工具处理const tool = step.toolResults.find( t => !t.dynamic && t.toolName === ToolName.generateReport) as StaticToolResult<Pick<StudyToolSet, ToolName.generateReport>>;if (tool?.output) { const token = tool.output.reportToken; // ← TypeScript 知道这个字段存在}// baseAgentRequest.tsif (webhookUrl) { await sendWebhook(webhookUrl, step);}