1. 项目概述一个为情侣设计的美食协作系统最近在做一个挺有意思的私人项目叫“甜蜜食记”。这名字听起来有点甜腻但核心想法其实挺实际的解决情侣之间那个永恒的难题——“今天吃什么”。我们不是简单地做个餐厅推荐App或者共享日历而是想构建一个完整的“她规划他执行”的协作流程。这个系统把从灵光一现到最终落地的整个决策链条给串了起来用AI找方向用地图探索用收藏夹沉淀最后用共享日历锁定计划。整个系统是典型的前后端分离架构。前端用Next.js搭了个React应用TypeScript保证类型安全后端是Python的FastAPIORM层用的SQLAlchemy数据库为了开发方便先上了SQLite。最核心的“大脑”部分我们接入了OpenAI的API并用LangGraph来构建一个能理解上下文、能调用工具比如分类、搜索的智能体。这个智能体不是个简单的聊天机器人它会根据对话者的角色“她”或“他”切换不同的“人格”和对话策略真正参与到规划流程里。这个项目适合两类人看一类是正在学习或实践全栈开发尤其是对Next.js、FastAPI和AI应用集成感兴趣的朋友另一类就是产品经理或独立开发者想看看一个具体的、带角色权限和AI协作的SaaS应用是怎么从想法落地成代码的。我会把架构设计、权限控制、AI智能体工作流这些关键环节都拆开揉碎了讲里面有不少我们在实际开发中踩过的坑和总结的技巧。2. 核心设计思路与架构拆解2.1 “角色分流”与“共享信息”的平衡术这个项目的核心矛盾或者说设计亮点就在于如何平衡“角色分流”和“信息共享”。“她”和“他”看到的是同一份日历、同一个收藏夹这是共享的基础。但操作权限必须严格区分“她”能增删改“他”基本上只能看。这个权限控制绝不能只依赖前端隐藏几个按钮那是自欺欺人。真正的防线必须建在后端。我们的做法是双保险。第一道保险在路由层。用户登录后后端会根据其角色her或him在JWT Token里标明前端拿到这个信息后通过一个叫RoleGuard的组件进行路由守卫。her登录直接进/her/calendarhim登录则进/him/calendar从入口就物理隔开。第二道也是最关键的一道保险在每一个后端API的service层。比如处理日历更新的update_meal_plan函数一上来就会检查当前请求用户的角色。如果是him无论前端发来什么请求直接返回403 Forbidden并记录一条安全日志。这样即使有人通过Postman之类的工具直接模拟API调用也绕不过权限校验。这里有个细节值得分享。我们最初把权限检查放在了router层觉得这样更“干净”。但后来发现很多业务逻辑本身就和角色强相关。比如“她”新建收藏时系统会自动调用AI工具对餐厅进行分类而“他”的收藏列表接口虽然数据结构一样但后端会刻意过滤掉一些“她”设置的私有标签。把这些逻辑都塞进router里会让代码变得臃肿。所以最终我们决定路由层只做最基础的路径映射和参数验证所有涉及角色和业务规则的判断统统下沉到service层。这样service就成了业务逻辑和权限控制的统一收口代码更好维护。2.2 前后端分离下的数据流与状态管理我们采用了经典的前后端分离架构但在这个具体项目里有几个设计选择是经过深思熟虑的。前端用Next.js的App Router而不是Pages Router主要是看中了其更好的布局Layout嵌套能力和对React Server Components的初步支持。虽然我们这个版本还没用上RSC但为未来升级留了空间。前后端通信全部通过RESTful API认证使用JWT。但和常见做法不同我们没有把Token放在Authorization: Bearer token的Header里返回给前端存LocalStorage。出于安全考虑我们让后端在登录成功后将JWT设置到一个httpOnly的Cookie中。这样Token对前端JavaScript不可见能有效防止XSS攻击窃取。前端只需要在Axios的全局配置里设置withCredentials: true浏览器就会在每次请求时自动带上这个Cookie。状态管理方面我们没有引入Redux或MobX这类重型状态库。对于服务器状态日历数据、收藏列表我们使用React Query来管理。它的缓存、后台刷新、乐观更新功能完全够用大大简化了代码。对于客户端状态比如地图上当前选中的餐厅、聊天框的输入内容直接用React的useState和useContext就搞定了。我们的原则是能用React Query的绝不用全局状态能用Context的绝不上Redux。这保证了项目在复杂度和开发体验之间取得了一个很好的平衡。数据流的一个典型例子是日历更新。1. “她”在前端日历组件点击某一天的午餐弹出编辑框。2. 填写餐厅信息后前端调用PUT /api/calendar并立即使用React Query的useMutation进行“乐观更新”——也就是在请求发出前先把本地缓存的数据更新成新值让UI立刻响应。3. 请求到达后端经过权限校验后执行upsert操作有则更新无则插入。4. 后端操作成功返回200。此时React Query的onSuccess回调会触发一次后台的invalidateQueries让日历数据重新从服务器拉取一次确保最终一致性。这个流程既快又稳。2.3 AI智能体工作流的设计不止于聊天AI模块是这个项目的“灵魂”。我们不想做一个只会闲聊的机器人而是希望它成为一个能真正参与工作流的“美食顾问”。为此我们引入了LangGraph来构建智能体。LangGraph允许我们用“图”的方式来定义对话流程节点是处理步骤边是条件跳转这比写一堆复杂的if-else清晰多了。智能体有两个核心“人格”分别服务两个角色。服务“她”的AI叫“糖糖”语气更贴心、更有引导性比如会说“宝宝今天想吃点清爽的吗我发现了附近一家新开的越南菜评分很高哦要不要先收藏起来看看” 而服务“他”的AI叫“阿哲”语气更务实、直接比如“明天晚餐订了‘老王烧烤’地址在XX路需要我帮你查一下停车信息吗”更重要的是这两个智能体都能调用“工具”。我们为它们装备了几个关键工具分类工具当用户说“这家店不错”时智能体可以调用此工具根据店名和地址让大模型判断它属于“中式快餐”、“日式料理”、“浪漫西餐”等哪个类别并自动打上标签。收藏工具智能体可以直接帮用户把正在讨论的餐厅加入收藏夹并关联上刚才分类的结果。日历工具用户可以说“那把这间店安排到下周六晚餐吧”智能体就能调用日历工具在对应的日期和餐别创建计划。搜索工具当用户需求模糊时智能体可以调用Tavily进行网络搜索获取最新的餐厅资讯或评价。这些工具调用对用户是透明的。整个对话以SSE流式进行用户看到的是AI在自然地思考和推荐背后其实是LangGraph在根据对话状态有条不紊地选择和执行工具。这里的一个关键技巧是工具调用的结果要自然地“编织”进AI的回复中。不能生硬地说“调用分类工具结果是‘日料’”。我们的Prompt会要求AI这样回复“这家‘深夜食堂’居酒屋氛围很赞归类在日式料理里了已经帮你收进‘深夜食堂’文件夹啦” 体验就流畅多了。3. 核心模块实现细节与实操要点3.1 用户认证与角色权限的深度实现认证系统是任何多角色应用的基石。我们采用用户名密码登录后端使用passlib的bcrypt算法对密码进行加盐哈希存储绝对明文存密码。登录成功后的关键在于如何安全且有效地传递角色信息。如前所述JWT Token被放在httpOnly的Cookie里。这个Token的Payload部分我们只存放了最必要的信息user_id和role。千万别把敏感信息或者整个用户对象塞进去。Token的签名密钥JWT_SECRET必须足够复杂且通过环境变量注入绝不能写死在代码里。后端的权限中间件RoleMiddleware会拦截所有以/api开头的请求。它从Cookie中取出Token进行验证和解码然后将解码出的用户信息主要是user_id和role存入FastAPI的Request.state中。这样后续所有的router和service函数都能通过request.state.user来获取当前用户信息无需重复解析Token。一个重要的实操心得权限检查要“早失败快失败”。我们不仅在通用的中间件里做验证在每一个需要特定角色的业务接口的service函数开头都会再次断言角色。例如在calendar_service.py的create_meal_plan函数里第一行代码可能就是if request.state.user.role ! “her”: raise HTTPException(status_code403, detail“Only ‘her’ role can create meal plans.”)这种“不信任原则”能最大程度避免权限漏洞。同时所有权限错误都被记录到日志中方便后期审计。3.2 日历模块唯一约束与并发处理日历模块的核心模型是meal_plans表它记录用户在特定日期plan_date的特定餐别meal_type早餐、午餐、晚餐的计划。这里有一个重要的业务约束同一个人同一天同一餐别只能有一个计划。你不能把午餐既安排成A餐厅又安排成B餐厅。在数据库层面我们通过SQLAlchemy的UniqueConstraint在(user_id, plan_date, meal_type)上建立了唯一约束。但在代码层面我们采用了upsert逻辑来处理更新。具体在calendar_repository.py中def upsert_meal_plan(self, user_id: int, plan_date: date, meal_type: str, data: MealPlanCreate): # 尝试查找现有记录 existing self.get_meal_plan_by_date_and_type(user_id, plan_date, meal_type) if existing: # 如果存在则更新 for key, value in data.dict(exclude_unsetTrue).items(): setattr(existing, key, value) existing.updated_at datetime.utcnow() self.db.commit() self.db.refresh(existing) return existing else: # 如果不存在则创建 new_plan MealPlan(user_iduser_id, plan_dateplan_date, meal_typemeal_type, **data.dict()) self.db.add(new_plan) self.db.commit() self.db.refresh(new_plan) return new_plan这个upsert操作封装在repository层对service层提供原子性操作。这样前端在调用PUT /api/calendar时无需关心是创建还是更新后端会自动处理。这里有个坑要注意并发下的upsert。如果两个请求同时针对同一个(user_id, date, type)发起upsert可能会造成重复创建尽管有唯一约束数据库会报错或更新丢失。对于这种个人使用的系统并发冲突概率极低我们目前靠数据库唯一约束来兜底。如果未来需要高并发可以考虑使用“先查后插失败则更新”的数据库事务或者使用更复杂的乐观锁机制。3.3 收藏模块AI驱动的自动分类收藏功能favorites的核心价值在于“有序”。一个杂乱无章的餐厅列表毫无用处。因此我们引入了AI自动分类。当“她”通过地图页面或AI对话添加一个收藏时后端不会直接存盘而是先调用一个分类工具。这个分类工具本身也是一个LangChain Tool。它会将餐厅的name和address信息发送给大模型并要求模型从我们预定义的一组类别如[“中式快餐”, “火锅烧烤”, “日韩料理”, “西餐酒吧”, “咖啡甜点”, “异国风味”]中选择最合适的一个并生成几个描述性的tags如[“辣”, “适合约会”, “平价”]。class CategorizeRestaurantTool(BaseTool): name “categorize_restaurant” description “Categorize a restaurant based on its name and address.” def _run(self, name: str, address: str): prompt f“””Based on the restaurant name ‘{name}’ and address ‘{address}’, categorize it into one of: {categories}. Also provide 2-3 descriptive tags.“”” llm_response call_llm(prompt) # 调用OpenAI API # 解析llm_response提取category和tags return {“category”: parsed_category, “tags”: parsed_tags}分类结果会和餐厅基本信息一起存入数据库。前端展示收藏列表时有两种视图一种是扁平的/api/favorites用于地图侧边栏快速展示另一种是分组的/api/favorites/grouped用于收藏主页面它会将数据按category字段分组返回前端渲染成一个个可折叠的“文件夹”体验类似手机相册非常直观。一个实用技巧分类的缓存与降级。频繁调用AI API分类既慢又贵。我们可以在service层加一个简单的缓存以餐厅名地址的MD5值为Key将分类结果缓存到Redis或内存中一段时间比如24小时。如果缓存命中直接使用缓存结果。同时设置一个降级策略如果AI服务不可用或超时就分配一个默认类别如“未分类”保证核心的收藏功能不受影响。3.4 地图集成与前端状态同步地图功能基于Google Maps Embed API这是一个相对简单且无需复杂SDK的集成方式。前端只需要一个iframe其src属性根据用户输入的关键词动态构建。例如搜索“台北市餐厅”src就会变成https://www.google.com/maps/embed/v1/search?keyYOUR_KEYq台北市餐厅。难点在于如何将地图上的操作与我们的应用状态同步。我们的实现是在地图页面左侧是地图iframe右侧是一个收藏侧边栏。当用户在地图上浏览看到一家感兴趣的店时他需要手动在侧边栏点击“添加收藏”按钮。此时前端会弹出一个表单自动抓取当前地图iframe的URL其中包含了地点信息并解析出店名和大致地址填充到表单中用户确认后提交。为什么不做得更自动化一点比如点击地图标记直接添加主要是因为Google Maps Embed API的交互能力有限它不允许外部JavaScript深度介入其内部的点击事件。更高级的集成需要使用Google Maps JavaScript API但那需要更复杂的配置和按次付费对于我们这个MVP版本来说Embed API的性价比更高。前端状态同步的另一个关键是React Query的合理使用。当地图页成功添加一个收藏后我们不仅要更新本地侧边栏的列表还要确保其他页面如收藏主页的数据也得到更新。我们通过调用queryClient.invalidateQueries([‘favorites’])来实现。这个操作会使所有queryKey包含‘favorites’的查询失效React Query会在后台自动重新获取数据从而保证整个应用状态的一致性。4. AI智能体与流式对话的工程实现4.1 基于LangGraph的智能体状态管理LangGraph的核心思想是将对话流程建模为一个有向图。我们的智能体图结构相对清晰主要包含以下几个节点路由节点根据最新的用户消息和当前的对话历史决定下一步是调用工具还是直接生成回复。这里我们根据消息内容是否包含“推荐”、“找”、“收藏”、“安排”等关键词来做简单路由更复杂的可以用一个小的分类模型。工具调用节点如果路由决定调用工具这个节点会解析出需要调用的工具名和参数然后执行具体的工具函数如分类、收藏、日历更新。工具结果处理节点接收工具执行的结果并将其格式化成自然语言片段合并到对话上下文中。回复生成节点最终将整合了工具结果的上下文发送给大模型生成一段连贯、自然的回复给用户。这个图是循环的。每次用户发送新消息智能体就从“路由节点”开始走完一个循环生成回复然后等待下一条消息状态也随之更新。LangGraph帮我们持久化这个“图状态”里面包含了完整的对话历史、已调用过的工具结果等确保多轮对话的连贯性。我们在backend/services/agent/目录下组织了这些代码。graph.py定义了图的结构state.py定义了状态的数据结构tools/目录下存放了所有可用的工具prompts/目录下则存放了给“糖糖”和“阿哲”的不同人格提示词。一个关键的工程决策将智能体状态存储在服务器端而非前端。我们为每个用户会话在服务器内存或Redis中维护一个独立的图状态。这样做的优点是逻辑集中、安全且不受前端刷新影响。缺点是增加了服务器状态管理的复杂度。我们通过一个会话ID来关联前端请求和服务器端的状态实例。4.2 服务端发送事件实现流式响应为了获得类似ChatGPT的逐字输出体验我们使用Server-Sent Events来实现流式响应。当前端调用POST /api/agent/chat时后端不是一次性生成全部回复再返回而是开启一个SSE流。FastAPI实现SSE非常优雅使用StreamingResponse并生成一个异步的生成器函数from fastapi.responses import StreamingResponse import asyncio async def chat_stream(session_id: str, message: str): # 1. 加载或创建用户的LangGraph状态 graph_state await load_agent_state(session_id) # 2. 将用户消息输入图并配置流式输出 async for event in graph.astream_events(input{“messages”: [(“user”, message)]}, …): if event[“event”] “on_chat_model_stream”: # 3. 捕获到大模型流式输出的每一个token token event[“data”][“chunk”].content if token: # 4. 以SSE格式发送给前端 yield f“data: {json.dumps({‘token’: token})}\n\n” await asyncio.sleep(0.01) # 控制流速避免前端卡顿 # 5. 流结束 yield “data: [DONE]\n\n” app.post(“/chat”) async def chat(request: Request): return StreamingResponse(chat_stream(session_id, message), media_type“text/event-stream”)前端使用EventSourceAPI来接收这个流。但注意EventSource只支持GET请求且功能有限。我们实际用的是Fetch API来模拟SSE因为它支持POST和更灵活的请求头设置。前端代码大致如下const response await fetch(‘/api/agent/chat’, { method: ‘POST’, headers: { ‘Content-Type’: ‘application/json’ }, body: JSON.stringify({ message: userInput, session_id }), }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value); // 解析SSE格式的“data: …”并更新UI }流式处理中的错误处理至关重要。网络可能中断AI API可能超时。我们的策略是在生成器函数内部用try…except包裹核心逻辑一旦发生错误就yield一个特殊的错误事件如data: {“error”: “Something went wrong”}\n\n。前端监听到错误事件后可以停止接收流并给用户一个友好的提示。同时要确保在发生错误或连接断开时清理服务器端的临时状态避免内存泄漏。4.3 提示词工程与角色人格塑造“糖糖”和“阿哲”不是靠换名字实现的而是靠两套不同的系统提示词。这些提示词定义在backend/services/agent/prompts/her_prompt.txt和him_prompt.txt中。“糖糖”的提示词会更强调共情、建议和引导“你是一个贴心、热爱美食的生活助手名叫糖糖。你的对话对象是负责规划美食行程的女主人。你的语气要温柔、活泼带点可爱。你的目标是帮助她发现美食灵感、整理收藏、并轻松地安排进日历。当推荐餐厅时不仅要说出名字还要描述氛围、特色菜并给出一个收藏或安排的理由。多用一些表情符号和鼓励的话语。”“阿哲”的提示词则更偏向信息提供和确认“你是一个务实、高效的美食信息助手名叫阿哲。你的对话对象是负责执行美食计划的男主人。你的语气要直接、清晰、可靠。你的主要功能是快速提供已收藏餐厅的详细信息、确认日历安排、并根据他的简单需求如‘辣的’、‘快的’进行筛选推荐。避免冗长的描述重点提供地址、营业时间、是否需要预订等关键信息。”塑造人格的关键在于细节。不仅仅是语气词还包括推荐策略。“糖糖”在推荐时可能会说“这家隐藏在小巷里的意大利面馆他们家的青酱是自己种的罗勒做的哦超级新鲜感觉很适合周五晚上放松一下要帮你先收藏起来吗” 而“阿哲”可能会说“‘老王烧烤’地址在中山路123号营业到凌晨2点。你周六晚上7点已经把它安排进日历了。需要我查一下附近的停车场吗”我们在代码中根据请求用户的角色动态加载对应的提示词文件并将其设置为LangGraph中聊天模型节点的系统消息。这样同一个AI模型就展现出了两种截然不同的“人格”极大地提升了用户体验的代入感和实用性。5. 部署、测试与常见问题排查5.1 从开发到生产环境配置与部署策略项目根目录的docker-compose.yml为生产部署提供了蓝图。它定义了两个服务backend和frontend以及一个潜在的postgres服务目前注释掉了用的是SQLite。后端Dockerfile的关键点FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install –no-cache-dir -r requirements.txt COPY . . CMD [“uvicorn”, “main:app”, “–host”, “0.0.0.0”, “–port”, “8000”]我们使用slim镜像以减少体积。requirements.txt必须通过pip freeze requirements.txt精确生成避免依赖冲突。生产环境运行时应去掉–reload参数。前端Dockerfile的关键点FROM node:20-alpine AS builder WORKDIR /app COPY package.json yarn.lock ./ RUN yarn install –frozen-lockfile COPY . . RUN yarn build FROM nginx:alpine COPY –frombuilder /app/out /usr/share/nginx/html COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 80这里采用多阶段构建。第一阶段用Node镜像安装依赖并构建生成静态文件在out目录。第二阶段用极小的Nginx镜像只复制构建产物。这比在同一个镜像里运行Node服务要轻量和安全得多。环境变量是部署的核心。必须创建一个.env.production文件包含所有必要的密钥# 后端 DATABASE_URLsqlite:///./prod.db # 或 postgresql://user:passdb:5432/sweetfood JWT_SECRETyour_super_strong_secret_here OPENAI_API_KEYsk-… GOOGLE_MAPS_API_KEY… TAVILY_API_KEY… # 可选 ENVproduction # 前端 (构建时注入) NEXT_PUBLIC_GOOGLE_MAPS_API_KEY… API_URLhttp://backend:8000 # Docker内部通信地址重要提示前端的NEXT_PUBLIC_*变量是在构建时被替换的这意味着一旦构建完成这些值就固定了。如果你需要动态改变API地址就不能用NEXT_PUBLIC_前缀而需要在运行时通过window.location或其他方式获取。5.2 测试策略单元测试、集成测试与E2E考量目前后端已有一些基于pytest的测试主要集中在services和routers层。测试数据库使用一个独立的SQLite内存数据库通过pytest的fixture在测试开始时创建表测试结束后销毁保证测试的独立性和可重复性。一个典型的权限测试例子def test_him_cannot_create_meal_plan(client, him_token): “””测试 him 角色无法创建餐点计划””” data {“plan_date”: “2023-10-01”, “meal_type”: “lunch”, “restaurant_name”: “Test Restaurant”} response client.put(“/api/calendar”, jsondata, cookies{“access_token”: him_token}) assert response.status_code 403 assert “Only ‘her’ role” in response.json()[“detail”]测试中的经验模拟外部服务测试AI相关功能时绝不能调用真实的OpenAI API。我们使用unittest.mock来模拟openai.ChatCompletion.create方法返回预设的响应。这样测试又快又便宜还不受网络影响。测试流式端点测试SSE流式接口有点棘手。我们编写了一个辅助函数来异步消费流并将其收集到一个列表中然后断言列表中的内容符合预期。前端测试的缺失目前项目缺少前端单元测试和E2E测试。一个可行的补充方案是使用Jest React Testing Library进行组件测试并使用Cypress或Playwright进行端到端测试模拟用户从登录到完成一个完整规划流程的操作。5.3 常见问题排查与性能优化点在实际开发和内部试用中我们遇到并解决了一些典型问题地图不显示或显示“开发中”原因Google Maps Embed API密钥未设置、设置错误或密钥的HTTP Referrer限制不正确。排查首先检查浏览器控制台是否有JavaScript错误。然后确认前端构建时NEXT_PUBLIC_GOOGLE_MAPS_API_KEY环境变量已正确注入。最后去Google Cloud Console检查该API密钥的“应用程序限制”确保你部署的域名或localhost在允许的“HTTP引荐来源网址”列表中。AI对话无响应或响应慢原因OpenAI API调用失败、网络延迟或LangGraph图状态处理卡住。排查查看后端日志确认OpenAI API调用是否返回错误如额度不足、密钥无效。检查网络连通性。如果是部署在境内服务器访问境外API延迟会很高考虑设置合理的超时时间如30秒。检查LangGraph的状态图是否陷入死循环。可以在关键节点添加日志打印当前状态和路由决策。日历更新后对方角色看不到变化原因前端缓存未及时失效。React Query默认有缓存策略可能还在使用旧数据。解决在成功完成日历更新PUT或DELETE的mutation后手动调用queryClient.invalidateQueries([‘calendar’])。确保queryKey完全一致。也可以考虑使用WebSocket或Server-Sent Events实现实时推送但对于此应用手动失效缓存已足够。数据库性能随着数据增长下降现状使用SQLite在数据量小几百条记录时非常快。预警当meal_plans和favorites表记录超过数万条时按月查询日历等操作可能变慢。优化索引确保在user_id、plan_date、meal_type、category等常用查询字段上建立了索引。分页收藏列表接口未来应支持分页避免一次性拉取上千条数据。数据库升级当性能成为瓶颈时将SQLite迁移到PostgreSQL。我们的SQLAlchemy代码是数据库无关的主要改动在于连接字符串和部署配置。Docker容器内后端服务无法连接数据库原因在docker-compose中如果数据库是另一个服务DATABASE_URL中的主机名应为服务名如db而不是localhost。解决确保docker-compose.yml中服务依赖关系正确backend依赖db并且backend的环境变量DATABASE_URL类似postgresql://user:passdb:5432/sweetfood。这个项目从构思到实现最大的体会是一个好的产品创意需要严谨的技术架构来支撑而细节处的用户体验往往取决于那些“微不足道”的技术决策。比如用httpOnlyCookie提升的那一点安全性用SSE带来的那一点流畅感用AI自动分类减少的那一步操作。把这些细节一个个打磨好整个系统才会真正变得“甜蜜”又好用。