酒店 PMS 装上「会说人话」的 AI 助手

AI1周前发布 beixibaobao
15 0 0

用 .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。

© 版权声明

相关文章