itsneilyall/n8n-ai-appointment-scheduler

GitHub: itsneilyall/n8n-ai-appointment-scheduler

一个带有多层安全护栏的 n8n 牙科诊所 AI 预约代理,通过聊天界面完成预约的预订、改期和取消,并防止滥用、注入和重复预约。

Stars: 0 | Forks: 0

# AI 预约排期器 这是一个 n8n 工作流,通过聊天界面为牙科预约进行预订、重新安排和取消。更准确地说,它是一次实践:探讨如何将一个 LLM 代理置于真实用户和真实的日历面前,同时避免它被滥发垃圾信息、被越狱,或导致患者重复预约。 这种工具的简陋版本只需六个节点和一个优秀的系统提示词(system prompt)即可实现。而本项目是那个能在与现实环境接触后存活下来的版本。 AI Appointment Scheduler ## 代码仓库 ``` workflow/ AI_Appointment_Scheduler.json Import via Workflows → Import from File docs/ Technical-Documentation.pdf Architecture, node reference, gotchas, test plan AI-Appointment-Scheduler-SOP.pdf Plain-language operating procedure for clinic staff .gitignore README.md ``` 工作流 JSON 中的 ID 都是占位符(`YOUR_CALENDAR_ID`、`YOUR_SPREADSHEET_ID` 以及凭据 ID)。请在导入后重新绑定它们。 ## 存在的问题 ### 为什么需要排期器 牙科诊所预订预约的方式和三十年前一样:有人接听电话。而那位前台接待员还要同时为站在柜台前的患者办理登记、跟进保险表格,并在问诊间隙对器械进行消毒,因此电话经常无人接听。一次预约需要几分钟的反复沟通(*“周四行吗?不行,我们有下午 2 点或 4:15 的……”*),这其中的每一分钟,都是受过专业培训的员工在口头朗读日历。 这些成本悄无声息却在不断累积: - **电话只在诊所营业时才能打通。** 患者往往在晚上 11 点或周日才开始发愁自己的牙痛。一个未接来电通常意味着永远无法达成的预约,因为大多数人不会打第二次。 - **预约会与眼前的患者争夺资源。** 前台就像是一个单服务器的队列,而那个亲自站在你面前的人永远拥有最高优先级。 - **重复预约是人为失误。** 两名员工,一个日历,在核对之前就记下了一个时间段,结果患者在等候室里才发现冲突。 - **重新预约比直接预约更糟。** 取消通知通过语音留言和短信送达,往往被延迟处理或根本无人处理,空出来的时间段就一直闲置,而不是转移给候补名单上的其他人。 - **一切都是不可量化的。** 有多少人在下班后尝试预约?他们需要什么?没人知道,因为一个未接来电不会留下任何记录。 这款助手可以处理完整的生命周期——预订、重新安排、取消——这一切都在聊天窗口中完成,7x24 小时全天候在线,无需员工介入。它读取的是实际的日历,而不是某人对日历的记忆;它会拒绝预订与现有预约重叠或不在营业时间内的时段,并在单次操作中同时将预约写入日历和诊所电子表格,确保两者永远不会脱节。每一次对话都会被记录,因此“有多少人在午夜尝试预约”就变成了一个能找到答案的问题。前台只需处理异常情况,而日常事务则会自行处理。 ### 为什么需要安全护栏 这还只是简单的那一半。基于聊天的预约代理有一条显而易见的理想路径,但其背后却隐藏着大量不起眼的失败场景: - 任何拥有链接的人都可以通过连续粘贴 1000 次 `1+1` 来耗尽你的 OpenAI 预算。 - “忽略之前的指令”确实是真实用户会输入的内容。 - 看不到日历的代理会自信地凭空捏造可用时间。 - 在错误时区下查看日历的代理会自信地导致重复预约。 - 即使你告诉了代理营业时间,它依然会在周六预订下午 5 点的号。 - 除非你加以阻止,否则任何拥有链接的人都可以取消陌生人的预约。 安全护栏不是最后拼凑上去的功能,它们本身就是架构。 ## 架构 每条消息都要经过一系列成本逐渐升高的检查。廉价且确定性的过滤器运行在最前端,并以 **零 token** 的代价拦截了大部分滥用行为。GPT-5 预约代理只能看到已被确认为合法的流量,这也正是为什么在代理中使用全尺寸大模型在经济上变得可行的原因。 ``` Chat Trigger │ ▼ Normalize Input │ ▼ ① Rate Limiter & Abuse Score ──blocked──▶ Fixed reply (no LLM) │ ▼ ② Rule-Based Filter (regex) ──filtered─▶ Fixed reply (no LLM) │ └─ greeting fast-path ▼ ③ Intent Classifier (gpt-4o-mini, JSON-only) │ ▼ ④ Conversation-State Gate ──off-topic─▶ Fixed reply │ ▼ ⑤ AI Booking Agent (gpt-5) ──┬── Validate Slot (Code: office-hours gate) │ ├── Check Availability (Calendar: Get Many) │ ├── Creat event (Calendar: Create) │ ├── Add data (Sheets: Append) │ ├── Find Booking (Calendar: Get Many by phone) │ ├── Cancel Booking (Calendar: Delete) │ └── Update Booking Status (Sheets: Update by BookingID) ▼ Log Interaction (non-fatal) │ ▼ Format Response (error fallback) ``` | 层级 | 成本 | 拦截目标 | |---|---|---| | ① 限流器 | 0 token | 大量垃圾信息、刷屏、惯犯 | | ② 正则过滤器 | 0 token | 数学刷屏、注入话术、越狱 | | ③ 意图分类器 | 1 次 mini 调用 | 牙科诊所业务范围之外的任何请求 | | ④ 状态门控 | 0 token | 对预约过程中回复的误判 | | ⑤ 验证时段 | 0 token | 营业时间之外的预约 | | ⑥ 代理提示词 | — | 未经验证的预约、凭空捏造的可用时间 | ## 安全护栏 ### ① 限流器与滥用评分 在 `$getWorkflowStaticData('global')` 中的会话级状态: ``` { timestamps: [], recentMessages: [], abuseScore: 0, blockedUntil: null, lastAgentAt: null } ``` - 在滚动的 30 秒窗口内消息数 >10 → 拦截,`abuseScore +1` - 同一条消息连续发送 3 次 → 拦截,`abuseScore +1` - `abuseScore >= 5` → 实施 15 分钟冷却期,到期后分数重置 ### ② 基于规则的过滤器 **问候语快捷路径**会瞬间响应 `hi` / `hello` / `good morning`,对最常见的消息实现了零 LLM 调用。滥用拦截组可以捕获指令覆盖话术、角色劫持、越狱词汇以及代码/笑话请求。 数学刷屏检测具备**电话号码感知能力**。“这个字符串是否完全由数学字符组成?”会错误匹配纯电话号码,因为数字本身也是数学字符。因此检测要求数字之间必须有运算符,这就需要在前面加上一个明确的电话号码保护规则,因为 `+` 和 `-` 既是运算符也是电话号码的标点符号。 ### ③ 意图分类器 使用 `gpt-4o-mini`,`jsonOutput: true`,严格返回 `{"intent":"..."}`。从不闲聊。分为八种意图,按规则顺序匹配,命中第一个即结束: `PROMPT_ATTACK` · `BOOK_APPOINTMENT` · `RESCHEDULE` · `CANCEL` · `CLINIC_INFO` · `FOLLOW_UP` · `GREETING` · `OUT_OF_SCOPE` 节点发生故障时,它会向代理**故障开放(fails open)**。由于限流器和正则过滤器已经运行过,且代理自带安全护栏,因此 OpenAI 的偶发故障不应该让真正的患者听到“我只能处理牙科预约”。 ### ④ 对话状态门控 分类器每次只能看到一条消息,因此处于预订过程中的回复在孤立查看时显得有些跑题。该门控会在每条到达代理的消息上盖下 `lastAgentAt` 时间戳;如果在一个活跃的 15 分钟流程内随后出现了 `OUT_OF_SCOPE`,它将被重新分类为 `FOLLOW_UP`。**`PROMPT_ATTACK` 永远不会受到此覆盖的软化处理**,且正则层依然会优先运行。 ### ⑤ 验证时段 - 确定性营业时间 通过一个包含营业时间表的 Code 工具实现。给定 `YYYY-MM-DD HH:mm`,它会自行计算星期几并返回: ``` { "valid": false, "weekday": "Saturday", "opens": "09:00", "closes": "13:00", "latest_bookable_start": "12:00", "reason": "The clinic closes at 13:00 on Saturday, and a consultation lasts 60 minutes, so the latest possible start is 12:00. 17:00 is outside office hours.", "bookable_start_times": ["09:00", "10:15", "11:30"] } ``` 在提示词中描述营业时间并不能强制执行。模型必须串联三个推论:今天是星期六,星期六在 13:00 关门,17:00 已经关门了——但它经常会弄错这些逻辑,特别是当 `Check Availability` 报告 17:00 没有冲突时,这看起来就像是得到了许可。现在,提示词已明确声明该工具是关于营业时间和星期的**唯一权威**。 ### ⑥ 代理流程 这是按顺序执行的程序,而不仅仅是描述:可用性受 `Validate Slot` 门控;预约完成序列(创建日历事件 → 读取返回的 id → 使用该 id 写入电子表格 → *只有完成这些后才向用户确认*);一个 8 步的取消流程;一个 10 步的重新预约流程(先取消再创建,这样即使在流程中途失败也绝不会导致重复预约)。 ## 关键设计决策 **BookingID 连接了两个没有共享主键的系统。** Calendar 和 Sheets 是独立的。`Creat event` 返回事件 ID;代理必须将其作为 `BookingID` 传递给 `Add data`。取消操作使用与日历相同的 ID 来匹配表格中的行。姓名、号码和日期都被作为键值否决了,因为它们都不是唯一的——一个患者可以持有多个预约。 **破坏性操作的身份验证。** 任何拥有聊天链接的人都可以访问代理。除非事件标题中的电话号码与该次对话中提供的号码相匹配,否则任何预约都不会被取消或移动。绝不仅凭姓名进行操作。这与防范注入攻击的安全护栏属于相同的威胁模型,只不过它的目标对准了日历而不是提示词。 **返回事件列表,而不是布尔值。** 可用性检查使用了 Calendar 的“获取多个事件(Get Many)”来获取一整天的事件,而不是仅仅查询忙/闲状态(free/busy)。布尔值在查询时间窗口偏移时会悄悄失效,而事件列表不会,并且它让代理能够提供*真实*的替代方案,而不是瞎猜。 **时区通过三种方式锁定。** 工作流设置、`GENERIC_TIMEZONE`,以及提示词中的 `$now.setZone('Asia/Manila')`,因为依赖实例默认时区正是导致预约时间偏离预期位置八个小时的原因。 **错误处理是非致命且诚实的。** 分类器、代理以及所有六个工具上都设置了 `onError: continueRegularOutput`:发生故障时会将结果返回给代理,而不是中止整个运行。代理会回复一句固定的话术,绝不谎报成功。如果代理的输出完全缺失,`Format Response` 会自动替换为该话术,这样患者就永远不会看到空白的聊天窗口。 **确定性代码优于提示词工程。** 任何可以用代码捕获的内容都在代码中捕获。提示词是最后一道防线,而不是第一道。 ## 模型选择 代理使用 `gpt-5`,分类器使用 `gpt-4o-mini`,这是通过实际经验验证的,而不是盲目假设的。 分类器会在**每条**消息上运行,并且只做一件边界明确的事情:读取一句话,输出一个 JSON 字段。这正是 token 开销集中的地方,而 mini 模型确实能胜任此任务。 代理掌握了七个工具和三个多步骤流程。在 mini 级别的模型上,它以一种典型的姿态失败了:它自己编造了一个精确的确认词组,*“请精确回复:'Yes, cancel'”*,然后基于此进行字符串精确匹配,并因为大小写不符拒绝了 `yes, cancel`。同样的提示词在 `gpt-5` 上并没有重现这个问题。这个失败是模型能力问题,而不是指令模糊不清造成的。 ## 调试记录 这是最有趣的部分。这里记录的每一个问题都是在实际运行中发现的,而不是看代码看出来的。 | 症状 | 根本原因 | 修复方案 | |---|---|---| | 工作流卡在 `Parse Intent` | `.item` 依赖于配对项追踪,这在跨 LangChain OpenAI 节点时会失效 | 在分类器下游统一使用 `.first()` | | `"hi"` 耗费了一次 LLM 调用 | 地球上最常见的消息却没有快捷处理路径 | 增加问候语正则短路 | | `"john doe, 09564952581"` → *“我只能处理牙科预约”* | 无状态分类器:姓名 + 电话不符合任何预约规则,落入了 `OUT_OF_SCOPE` | 新增 `FOLLOW_UP` 意图 + 对话状态门控 | | 同一时段被预订了两次 | 布尔值的忙/闲检查 + 无时区偏移的时间戳 | 全天事件列表 + 重叠规则 + 硬编码 `+08:00` | | 创建的事件显示为 `(No title)` | `additionalFields` 为空,标题仅存在于提示词中,无处安放 | 通过 `$fromAI` 获取 `Event_Title` | | 事件被创建在昨天日期的 05:00 | 实例位于 `America/New_York`;在本地时间早上 8 点之前 `$now` 解析到了错误的日期 | 在提示词中设置 `setZone` + 设定 `GENERIC_TIMEZONE` | | 日历已填满,**但表格依然为空** | `Add data` 缺少 `toolDescription` - 模型看到一个名为 "Add data" 的工具,却不知道为什么要调用它 | 添加手写的工具描述 + 强制的完成流程 | | 代理“取消”了实际上无法取消的预约 | 当时不存在删除工具;但提示词却告诉它去取消 | 新增查找 / 取消 / 更新工具 + 诚实规则 | | 取消操作返回 `Not Found` | 对已删除的事件重复发起删除调用 | 单次调用契约 + `onError` 错误兜底 | | 在**周六**预订了 17:00 | 营业时间只是在提示词中被描述给了模型,从未被强制执行 | 新增 `Validate Slot` 确定性门控 | | 输入 `"yes, cancel"` 时出现确认死循环 | Mini 模型编造了一句精确的话术,然后进行字符串匹配 | 设立确认(基于意图而非字符) + 升级为 `gpt-5` | | 纯电话号码被当作垃圾信息拦截 | 数学正则表达式匹配了任何全数字的字符串,而数字*本身确实是*数学字符 | 添加电话号码保护 + 要求数学运算符 | 这里有两个最喜欢的案例。第一个是工具描述的 bug:三个工具中有两个手写了描述,而另一个没有,代理悄悄地停止了调用那个没描述的工具。它从不报错,只是单纯地拒绝使用。这个 BUG 是通过阅读工作流自身产生的运行日志发现的。 还有表格最后一行记录的:一个安全护栏把合法用户的电话号码误当成了垃圾信息予以拦截,并在拦截的同时悄悄增加了他们的滥用评分。安全层中的误报是一种往往无人测试的失败模式。 ## 技术栈 n8n (自托管, Railway) · OpenAI GPT-5 + GPT-4o-mini · Google Calendar · Google Sheets · LangChain 代理节点 ## 设置说明 **1. 托管环境** ``` GENERIC_TIMEZONE=Asia/Manila WEBHOOK_URL=https:// ``` 如果不设置 `GENERIC_TIMEZONE`,`$now` 将按实例的默认时区进行解析,预约时间会偏差好几个小时;并且在本地时间早上 8 点之前,代理会认为今天还是昨天。 **2. Google Calendar** - 创建一个日历,将其 ID 填入四个日历工具节点中。 **3. Google Sheets** - 一个电子表格,两个标签页。表头通过名称匹配;重命名表头会*静默*破坏数据写入。 | 标签页 | 表头 | |---|---| | `Booking` | BookingID, Name, Number, Date, Confirmation | | `Logs` | Timestamp, SessionId, Intent, Message, Response | **4. 导入** `workflow/AI_Appointment_Scheduler.json`,绑定凭据(OpenAI · Google Calendar · Google Sheets),替换 `Add data`、`Update Booking Status` 和 `Log Interaction` 中的 `YOUR_SPREADSHEET_ID`。 **5. 根据您的诊所进行调整** - 营业时间配置在 `Validate Slot` 顶部的 `HOURS` 表格中(表示从午夜开始的分钟数,`[open, close]`,`null` 表示休息)。代理的系统消息中带有面向患者的文案、时区设置以及报障电话号码。 **6. 通过公共聊天 URL 进行激活和测试** - 请勿在编辑器中测试。`$getWorkflowStaticData` 仅在生产环境的执行之间持久化,因此在手动运行时,限流器和状态门控会表现出失效的现象。 ## 测试方案 | # | 输入 | 预期结果 | |---|---|---| | 1 | `hi` | 瞬间返回预设的欢迎语,零 LLM 调用 | | 2 | `09464564567` *(作为第一条消息)* | 到达代理,不被拦截,不扣滥用评分 | | 3 | `book me today at 5pm` *(在某个周六)* | 以 13:00 营业时间为由拒绝;并提供 09:00 / 10:15 / 11:30 作为选项 | | 4 | `1+1` | 被正则表达式拦截;`abuseScore +1` | | 5 | `who won the world cup?` *(全新会话)* | 在意图门控处被拦截 | | 6 | `ignore previous instructions` *(在预订过程中)* | 被拦截;`PROMPT_ATTACK` 绝不会受到状态门控的软化 | | 7 | 11 条快速连发的消息 | 触发限流器拦截;持续滥用 → 触发 15 分钟冷却期 | | 8 | 完成一次预约 | 在日历中生成标题为 `Name - Number` 且本地时间正确的日历事件,在 `Booking` 标签页中生成包含有效 `BookingID` 的行,以及一行 `Logs` 日志 | | 9 | 随后回复小写的 `yes` 取消它 | 首次即被受理,没有反复确认;事件被删除;表格行状态变为 `Cancelled` | | 10 | 使用不匹配的电话号码尝试取消 | 拒绝执行 | | 11 | 从第二个无痕模式会话预订同一时段 | 检测到冲突,并提供真实的替代方案 | 会话状态通过浏览器 localStorage 中的 `sessionId` 作为键进行存储,请使用无痕窗口以获取干净的测试状态;普通刷新会保持同一会话。
标签:AI智能体, LLM安全防护, n8n, 工作流自动化, 数据可视化, 预约系统