酒店 PMS 装上「会说人话」的 AI 助手
用 .NET 10 + Vue3 给酒店 PMS 装上「会说人话」的 AI 助手:Function Calling 全栈实战
「把 8203 房间改成维修中,备注空调坏了」——前台一句人话,AI 自动翻译成结构化的房态修改操作,看板实时刷新。
这篇文章手把手拆解一个最小可运行的酒旅 PMS AI 助手:Vue3 前端 + .NET 10 后端,核心只有 两个工具,却跑通了一整套 Function Calling(工具调用)链路。
一、先看效果
这是一个酒店管理系统(PMS)的 AI 助手 Demo,用户用自然语言就能:
- 查订单:「查一下张伟的订单」「今天有哪些客人入住?」
- 改房态:「8203 设为清扫中」「把 8202 退房后改成可售」
背后没有写一行「如果用户说 XX 就执行 YY」的硬编码规则。人话到操作的翻译,全部交给大模型的 Function Calling 能力完成。
技术栈:
| 层 | 技术 |
|---|---|
| 前端 | Vue3 + Vite + Pinia + TypeScript |
| 后端 | .NET 10 + ASP.NET Core + Microsoft.Extensions.AI
|
| 数据 | EF Core 内存库(演示用,零配置) |
| 大模型 | 任意 OpenAI 兼容服务(通义/智谱/Kimi/本地 vLLM 均可) |
二、一句话讲清 Function Calling
很多人以为「AI 操作数据库」是让大模型直接生成 SQL 去跑——千万别。正确姿势是:
你把几个 C# 方法「描述」给大模型,模型不执行代码,它只决定调哪个方法、传什么参数;真正的执行权牢牢攥在你自己手里。
整个循环长这样:
用户提问
↓
①调模型(带上工具定义)
↓
模型说:「我要调 UpdateRoomStatus(roomNo=8203, targetStatus=维修中)」
↓
②你的代码执行这个方法 → 业务层 → 数据库
↓
③把执行结果回灌给模型
↓
④模型基于真实结果,生成给人看的回答
这套「调模型 → 执行工具 → 回灌结果 → 再调模型」的循环,是 Function Calling 的灵魂。下文你会看到它的两种实现:框架自动版和手写版。
三、架构铁律:AI 碰不到数据库
整个项目最重要的一条原则,写在 CLAUDE.md 里:
AI 只能调两个工具,工具一律落到业务层
Services/,绝不让工具直接操作 DbContext。
LLM ──只能调──▶ 工具方法(PmsTools) ──▶ 业务服务(Services) ──▶ DbContext
↑
权限校验 / 状态机 / 审计日志都在这一层兜底
为什么这么设计?因为大模型会幻觉。它可能给你一个不存在的房间号,或者一个不合法的状态流转。只要把校验放在业务层,哪怕模型胡说八道,脏数据也进不了库。
四、核心代码拆解
4.1 工具定义:[Description] 写得越清楚,路由越准
暴露给 LLM 的全部能力,就这一个文件 Ai/PmsTools.cs:
/// <summary>
/// 暴露给大模型的"工具"。AI 只能调这两个方法,碰不到数据库。
/// [Description] 会作为工具/参数说明发给 LLM,写得越清楚路由越准。
/// </summary>
public class PmsTools(OrderService orders, RoomStatusService rooms)
{
[Description("根据条件查询酒店订单。当用户想了解订单、入住、退房、客人、房间预订情况时调用。")]
public async Task<object> QueryOrders(
[Description("客人姓名,模糊匹配,可选")] string? guestName = null,
[Description("房间号,如 8202,可选")] string? roomNo = null,
[Description("订单状态,只能是:已确认/已入住/已退房/已取消,可选")] string? status = null,
[Description("入住日期起,格式 yyyy-MM-dd,可选")] string? checkInFrom = null,
[Description("入住日期止,格式 yyyy-MM-dd,可选")] string? checkInTo = null)
{
// ...查询并返回 { count, orders }
}
[Description("修改指定房间的房态。当用户要把某房间设为可售/已占用/维修中/清扫中/锁房时调用。这是写操作,请确保房间号和目标状态明确。")]
public async Task<object> UpdateRoomStatus(
[Description("房间号,必填,如 8203")] string roomNo,
[Description("目标状态,必填,只能是:可售/已占用/维修中/清扫中/锁房")] string targetStatus,
[Description("备注/原因,可选,如 空调维修")] string? remark = null)
{
var r = await rooms.UpdateAsync(roomNo, targetStatus, remark, "AI助手");
return new { success = r.Success, message = r.Message, room = r.Room };
}
}
关键认知:那些 [Description] 特性不是写给人看的注释,它们会原样发给大模型,是 AI 路由判断的唯一依据。想调整 AI 的行为,第一选择是改这些描述,而不是加 if-else。
4.2 一个被低估的技巧:中文枚举贯穿全栈
/// <summary>房态。中文值同时用于 AI 工具的参数描述,避免来回翻译。</summary>
public enum RoomStatus
{
可售,
已占用,
维修中,
清扫中,
锁房
}
是的,C# 枚举成员直接用中文。这不是炫技,而是刻意设计:
- 模型看到的工具描述里写的是「维修中」;
- 模型回传的参数也是「维修中」;
- 业务层
Enum.TryParse<RoomStatus>("维修中")直接转成枚举。
全链路零翻译,少一层「英文 enum ↔ 中文展示」的映射,也少一个出错点。
4.3 业务层兜底:状态机 + 审计
工具最终都落到 RoomStatusService.UpdateAsync,校验和审计都在这里:
public async Task<UpdateRoomResult> UpdateAsync(string roomNo, string targetStatus, string? remark, string @operator)
{
// ① 参数合法性:模型给的状态字符串可能是瞎编的
if (!Enum.TryParse<RoomStatus>(targetStatus, out var target))
return new(false, $"无效的目标状态:{targetStatus}。可选:可售/已占用/维修中/清扫中/锁房", null);
var room = await db.Rooms.FirstOrDefaultAsync(r => r.RoomNo == roomNo);
if (room is null)
return new(false, $"房间 {roomNo} 不存在", null);
// ② 状态机:已占用的房间不允许直接设为可售(需先退房)
if (room.Status == RoomStatus.已占用 && target == RoomStatus.可售)
return new(false, $"房间 {roomNo} 当前已占用,不能直接置为可售,请先办理退房", null);
var old = room.Status;
room.Status = target;
room.Remark = remark ?? room.Remark;
// ③ 审计日志:每次成功修改都留痕
db.AuditLogs.Add(new AuditLog
{
Operator = @operator, // 当前写死 "AI助手",生产接登录取真实操作人
Action = "UpdateRoomStatus",
Target = $"房间 {roomNo}",
Detail = $"{old} → {target}" + (remark is null ? "" : $"({remark})"),
CreatedAt = DateTimeOffset.Now
});
await db.SaveChangesAsync();
return new(true, $"房间 {roomNo} 已从「{old}」改为「{target}」", /* ... */);
}
注意:返回的错误信息(房间 8202 当前已占用,不能直接置为可售...)会被回灌给模型,模型读懂后会用人话向用户解释「这个房间还有人住,得先退房哦」。错误处理也成了对话的一部分。
4.4 注册大模型:OpenAI 兼容协议 + 自动工具循环
Program.cs 里把 IChatClient 装好,关键是那行 .UseFunctionInvocation():
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), openAiOptions);
builder.Services.AddChatClient(
openAiClient.GetChatClient(model)
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation() // ★ 自动处理「调模型→执行工具→回灌→再调」整套循环
.Build());
挂上 .UseFunctionInvocation() 后,框架的 FunctionInvokingChatClient 会替你把第二节那个循环全跑完——你只管发一句话,它把工具执行、结果回灌、多轮交互都处理好,最后吐出最终答案。
4.5 流式输出:SSE + fetch reader
生产路径 POST /api/chat 用 SSE(Server-Sent Events)流式返回,让回答像打字机一样逐字蹦出:
[HttpPost]
public async Task Post([FromBody] ChatRequest req)
{
Response.Headers.ContentType = "text/event-stream";
var history = store.GetOrCreate(req.SessionId);
history.Add(new ChatMessage(ChatRole.User, req.Message));
var options = new ChatOptions
{
Tools = [
AIFunctionFactory.Create(tools.QueryOrders),
AIFunctionFactory.Create(tools.UpdateRoomStatus)
]
};
await foreach (var update in chat.GetStreamingResponseAsync(history, options, ct))
{
foreach (var content in update.Contents)
{
switch (content)
{
case TextContent t when !string.IsNullOrEmpty(t.Text):
await Send("text", new { text = t.Text }); // 文字片段
break;
case FunctionCallContent call:
await Send("tool", new { name = call.Name, status = "calling" }); // 工具调用提示
break;
case FunctionResultContent result:
await Send("tool", new { name = result.CallId, status = "done" });
break;
}
}
}
await Send("done", new { });
}
小坑:前端不能用
EventSource读这个流,因为EventSource只支持 GET。这里前端用的是fetch+ReadableStream(见下文)。
前端 Pinia store 消费这个流,并用一个 dataVersion 计数器驱动看板刷新:
await streamChat(sessionId, text, (e) => {
if (e.type === 'text') assistant.content += e.payload.text
else if (e.type === 'tool' && e.payload.status === 'calling') {
// 字符串硬匹配工具名:只有"写"操作才需要刷新看板
const isWrite = e.payload.name === 'UpdateRoomStatus'
if (isWrite) mutated = true
assistant.tools.push(isWrite ? '修改房态' : '查询订单')
}
}, controller.signal)
// ...
if (mutated) dataVersion.value++ // 写操作完成,通知右侧看板自动刷新
五、彩蛋:手写一遍 Function Calling 循环
光会用框架的 .UseFunctionInvocation() 是「知其然」。项目里特意留了一个教学对照版 POST /api/chat-manual,它的 IChatClient 故意不挂 .UseFunctionInvocation(),用一个 for 循环把框架内部的活儿亲手写一遍:
// 注意这个 client 没有 .UseFunctionInvocation():能发请求,但不会自动执行工具
IChatClient raw = new OpenAIClient(/*...*/).GetChatClient(model).AsIChatClient();
var tools = new Dictionary<string, AIFunction>
{
["QueryOrders"] = AIFunctionFactory.Create(toolsImpl.QueryOrders),
["UpdateRoomStatus"] = AIFunctionFactory.Create(toolsImpl.UpdateRoomStatus),
};
var options = new ChatOptions { Tools = [.. tools.Values] };
// ★ 手写自驱动循环(框架内部就是这套逻辑)★
for (var round = 1; round <= 5; round++) // 上限 5 轮,防死循环
{
// 1) 问模型(带上工具定义)
var response = await raw.GetResponseAsync(messages, options, ct);
messages.AddRange(response.Messages);
// 2) 找出模型这轮想调用的工具
var calls = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionCallContent>()
.ToList();
// 3) 没有工具调用 = 模型给了最终答案,结束
if (calls.Count == 0)
return Ok(new { answer = response.Text, rounds = round });
// 4) 逐个执行工具,把结果作为 Tool 消息回灌
foreach (var call in calls)
{
var fn = tools[call.Name]; // 按名字找方法
var args = new AIFunctionArguments(call.Arguments); // 模型给的 JSON 参数
var result = await fn.InvokeAsync(args, ct); // 真正执行 → 业务层 → 数据库
messages.Add(new ChatMessage(ChatRole.Tool,
[new FunctionResultContent(call.CallId, result)]));
}
// 5) 带着工具结果进下一轮,模型基于真实数据继续生成
}
把这段和第二节那张循环图对照着看,Function Calling 就再没有黑盒了。这个接口还会返回一份 trace,记录每一轮模型说了什么、调了什么工具、拿到什么结果——调试和教学都很香。
六、踩坑记录
坑 1:思考模型 + 多轮工具调用 = HTTP 400
用 Kimi K2.6 这类默认开思考的模型时,多轮工具调用会报 reasoning_content is missing。
原因:这类模型要求每条带工具调用的 assistant 消息都携带 reasoning_content 字段,但 Microsoft.Extensions.AI 回灌历史时会把它弄丢。
解法:写一个 PipelinePolicy,给请求体注入 {"thinking":{"type":"disabled"}} 关掉思考:
public sealed class DisableThinkingPolicy : PipelinePolicy
{
private static void Patch(PipelineMessage message)
{
// 只改 /chat/completions 请求
if (req.Uri is null || !req.Uri.AbsolutePath.EndsWith("/chat/completions", ...)) return;
if (JsonNode.Parse(ms) is not JsonObject obj) return;
if (obj.ContainsKey("thinking")) return;
obj["thinking"] = new JsonObject { ["type"] = "disabled" }; // ★ 注入这一行
req.Content = BinaryContent.Create(BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(obj)));
}
}
用 Llm:DisableThinking 开关控制,只对支持该字段的服务开启,标准 OpenAI 不要开(会报未知参数)。
坑 2:大模型不知道「今天」是哪天
问「今天有谁入住」,模型会瞎编日期。解法是在 system prompt 里注入真实日期:
return $"""
当前日期:{now:yyyy-MM-dd}({week[(int)now.DayOfWeek]})。用户提到"今天/明天/昨天"等相对时间时,一律以此日期为基准换算。
你是一名酒店 PMS AI 助手...
- 涉及修改房态等写操作时,先用一句话向用户复述将要执行的操作,再调用工具。
- 不要编造数据库里没有的订单或房间,一切以工具返回结果为准。
""";
坑 3:API Key 别进库
appsettings.json 里不保留密钥,只走环境变量(.NET 会自动把 Llm__ApiKey 映射成配置 Llm:ApiKey):
export Llm__ApiKey=sk-xxxx # bash
$env:Llm__ApiKey="sk-xxxx" # PowerShell
缺失时 Program.cs 直接抛带指引的异常,避免裸奔。
七、从 Demo 到生产,还差这几步
这是个最小可运行工程,去掉了生产噪音,但也留好了改造点:
| 当前(演示) | 生产 |
|---|---|
| EF Core 内存库 | Npgsql / Pomelo.MySql + 迁移 |
操作人写死 "AI助手"
|
接登录态取真实操作人 |
| 写操作自动执行 | 工具返回 pending,前端弹二次确认 |
| 会话存内存字典 | Redis |
/api/chat 无限流 |
加限流,控住 token 成本 |
八、小结
这个项目想说明一件事:给传统业务系统接 AI,门槛比想象中低,但边界一定要守住。
- Function Calling 是「人话 → 结构化操作」的翻译器,不是让 AI 直连数据库的后门;
-
工具的
[Description]就是路由逻辑,调行为优先改描述而非加代码; - 业务层兜底校验是安全底线,模型再幻觉也污染不了数据;
- 中文枚举贯穿全栈,省掉一整层翻译映射。
如果你也在给自己的系统加 AI 入口,希望这套「两个工具 + 业务层兜底」的最小骨架能帮到你。
觉得有用的话,点个赞收藏一下~ 评论区聊聊你想给哪个系统接 AI。