用人话查订单、改房态 —— 一个能跑起来、能读懂、能改造的 Function Calling 实战范例
Vue3 + .NET 10,约 20 个源文件,零配置(内存库)即可启动。 专为想搞懂「大模型怎么调用工具操作业务系统」的开发者准备。
中文 · 注释/提交/文档全中文 · 任意 OpenAI 兼容大模型(通义/智谱/OpenAI/本地)
一个最小但完整的酒店管理系统(PMS)AI 助手。你对它说人话:
「8203 房间清扫完了,设为可售」
它背后通过 Function Calling(工具调用) 把这句话翻译成结构化操作,调用业务层真正改掉数据库里的房态,再用自然语言回复你。
它不是又一个「贴个 ChatGPT 框」的玩具,而是想认真回答一个问题:
怎么让大模型安全地操作真实业务系统,而不是让它满嘴跑火车污染数据?
| 🔒 AI 碰不到数据库 | 大模型只能调两个工具,工具一律落到业务层。权限、状态机、审计全在业务层兜底——模型再怎么幻觉也污染不了数据。这是全项目最重要的一条架构红线。 |
| 🔬 手写 vs 框架,双路径对照 | /api/chat 用框架自动跑工具调用循环(生产路径);/api/chat-manual 用一个手写 for 循环复现框架内部那套逻辑,并返回 trace 让你看清每一轮模型在想什么、调了什么工具、拿到什么结果。这是本项目最适合学习的部分。 |
| 🌊 真·流式 SSE | 后端 text/event-stream,前端 fetch + ReadableStream 边收边渲染,写操作完成后看板自动刷新。 |
Vue3 前端 ──SSE流式对话──▶ .NET 10 后端
├ AI 对话面板 ├ Chat 编排 (Microsoft.Extensions.AI)
├ 房态看板 │ ├ Tool: 查询订单 ┐
└ 订单列表 │ └ Tool: 修改房态 ┘ 只能调业务层
├ Domain Service (权限/状态机/审计兜底)
└ EF Core 10 → 内存库(演示) / PG·MySQL(生产)
一句话原则:工具方法 → 业务服务 → DbContext,绝不让工具直接碰数据库。这条边界是新增任何 AI 能力时都不能破的底线。
前置:.NET 10 SDK + Node.js 18+,以及一个 OpenAI 兼容大模型的 API Key。
API Key 走环境变量(不进 git,更安全):
# bash / macOS / Linux
export Llm__ApiKey=sk-xxxx
# Windows PowerShell
$env:Llm__ApiKey="sk-xxxx"BaseUrl / Model 在 backend/PmsAi.Api/appsettings.json 里改。任意 OpenAI 兼容服务都行,只改这两项:
| 服务 | BaseUrl | Model 示例 |
|---|---|---|
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-plus |
| 智谱 GLM | https://open.bigmodel.cn/api/paas/v4 |
glm-4 |
| OpenAI | https://api.openai.com/v1 |
gpt-4o |
| 本地 Ollama | http://localhost:11434/v1 |
你部署的模型 |
💡 Kimi/Moonshot 等默认开思考的模型,需在
appsettings.json把Llm:DisableThinking设为true,否则多轮工具调用会报 400。标准 OpenAI 不要开。原理见下文「踩坑记录」。
cd backend/PmsAi.Api
dotnet run内存库自动灌入演示房间和订单。验证:浏览器打开 http://localhost:5080/api/rooms 应看到房态 JSON。
cd frontend
npm install
npm run dev打开 http://localhost:5173。左侧 AI 对话,右侧房态看板 / 订单列表。Vite 已把 /api 代理到 5080,无需额外跨域配置。
查一下张伟的订单
今天有哪些已入住的订单?
8203房间清扫完了,设为可售
把8205设成维修中,原因是水管漏水
这个项目刻意做小,是为了让你一个下午读懂全部代码。读完你会理解:
- Function Calling 到底是什么:不是魔法,就是「模型输出一段 JSON 说要调哪个函数 → 你的代码真的去调 → 把结果喂回模型 → 模型继续」的循环。
- 怎么把「人话」路由到正确的函数:靠工具方法上的
[Description]特性——它原样发给大模型,写得越清楚路由越准。调 AI 行为优先改描述,而不是加代码。 - 怎么给 AI 套上安全护栏:让它只能调有限的几个工具,所有校验下沉到业务层。
- 流式 SSE 前后端怎么配合:为什么用
fetch而不是EventSource(后者不支持 POST)。
大多数教程直接用 .UseFunctionInvocation() 一行搞定,你却不知道里面发生了什么。本项目特意准备了对照组:
/api/chat(生产) |
/api/chat-manual(教学) |
|
|---|---|---|
| 工具调用循环 | 框架自动(.UseFunctionInvocation()) |
手写 for 循环,逐行可读 |
| 响应方式 | 流式 SSE | 非流式,返回完整 trace |
| 适合 | 上线用 | 看懂原理用 |
打开 Controllers/ManualChatController.cs,那段带详细注释的 for 循环,就是框架内部 FunctionInvokingChatClient 替你做的事——「按名字找工具 → 反序列化参数 → 真正执行 → 回灌结果 → 再问模型」。看懂它,你就真的懂 Function Calling 了。
Ai/PmsTools.cs—— 暴露给 AI 的两个工具,先看[Description]怎么写Controllers/ManualChatController.cs—— 手写循环,理解工具调用的完整链路Services/RoomStatusService.cs—— 业务层兜底:房态状态机 + 审计日志Controllers/ChatController.cs+Program.cs—— 生产版怎么用框架 + 流式frontend/src/api/client.ts+stores/chat.ts—— 前端怎么读流、怎么触发刷新
pms-ai/
├── backend/PmsAi.Api/
│ ├── Program.cs # 启动 + DI + LLM 接入
│ ├── Models/ # Room / Order / AuditLog(中文枚举贯穿全栈)
│ ├── Data/ # DbContext + 种子数据
│ ├── Services/ # 业务层(AI 的兜底:权限/状态机/审计)
│ ├── Ai/
│ │ ├── PmsTools.cs # ★ 暴露给 LLM 的两个工具
│ │ ├── ChatSessionStore.cs # 按 sessionId 的会话记忆
│ │ └── DisableThinkingPolicy.cs # 思考模型兼容补丁
│ └── Controllers/
│ ├── ChatController.cs # /api/chat 流式(生产)
│ ├── ManualChatController.cs # ★ /api/chat-manual 手写循环(教学)
│ └── PmsController.cs # /api/rooms /api/orders 看板接口
└── frontend/src/
├── api/client.ts # SSE 流式读取
├── stores/chat.ts # 对话状态 + 写操作触发刷新
└── views/ # ChatAssistant / RoomBoard / OrderList
- AI 编排:
Microsoft.Extensions.AI+Microsoft.Extensions.AI.OpenAI,UseFunctionInvocation()自动处理工具调用循环。 - 中文枚举贯穿全栈:
RoomStatus(可售/已占用/维修中/清扫中/锁房)、OrderStatus(已确认/已入住/已退房/已取消)故意用中文枚举成员——枚举值同时就是 AI 工具的参数取值,省掉来回翻译。也可用特性(Attribute)做值映射来实现同样效果,但本项目重点不在此,故从简。 - 房态状态机:合法性校验集中在
RoomStatusService.UpdateAsync,目前硬规则是「已占用的房间不能直接置为可售(需先退房)」,每次成功修改写一条AuditLog。新增流转规则就加在这一处。 - 会话记忆:
ChatSessionStore是按sessionId的内存字典,首次创建注入 system prompt。
- 思考模型 + 多轮工具调用冲突:Kimi 等默认开思考的模型,要求每条带工具调用的消息携带
reasoning_content,但框架回灌历史时会丢这个字段,导致 400「reasoning_content is missing」。解法是DisableThinkingPolicy——给请求体注入{"thinking":{"type":"disabled"}},由Llm:DisableThinking开关控制。 - 写操作触发前端刷新:前端靠
chatstore 的dataVersion计数器驱动看板刷新,判断「写操作」是硬匹配工具名UpdateRoomStatus——新增写工具时记得同步stores/chat.ts。 - 流式只能用
fetch:EventSource不支持 POST,所以前端用fetch+ReadableStream手动读 SSE 流。
这个项目是「能跑的骨架」,离上线还差这些(每一条都是很好的练手课题):
| 当前(演示) | 生产建议 |
|---|---|
| EF Core 内存库 | 换 Npgsql(PG)或 Pomelo.MySql,加迁移 |
操作人写死 "AI助手" |
接入登录 + JWT,工具里取真实操作人做权限校验 |
| 修改房态自动执行 | 写操作加二次确认:工具返回 pending,前端弹确认按钮再提交 |
| 会话存内存 | 换 Redis,支持多实例 |
| 无限流 | 给 /api/chat 加限流,控制 token 成本 |
- 觉得有帮助,点个 ⭐ Star 是最大的鼓励,也能让更多刚入行的小伙伴看到。
- 发现 bug、有改进想法、想加新工具?欢迎提 Issue 或 PR。
- 这个项目定位是教学范例,所以特别欢迎「这里我没看懂」类的提问——你的困惑能帮我把注释和文档写得更好。
MIT © yangka
