AIGC标识 .NET Core 与大模型交互实战(二):增加一个工具

这是《.NET Core 与大模型交互》系列的第二篇。第一篇我们实现了基础对话与 SSE 流式输出;本篇将在其基础上,为大模型增加一个“天气查询”工具,实现 Function Calling / Tool Use 的完整闭环。


系列大纲

本系列共三篇,围绕同一个 .NET Web API 项目逐步演进:

  1. 基本对话与流式响应
    搭建 .NET 后端与大模型的最小交互链路,实现 SSE 流式输出,让前端可以“边生成边显示”。

  2. 增加一个工具
    在流式对话中引入工具调用能力,让大模型在需要时调用本地服务(如天气查询),再把结果回传给模型继续生成。

  3. 流式消息中断恢复
    解决流式传输中的断连、重连问题,实现消息中断后的状态恢复与续写,提升生产环境可用性。


一、为什么需要工具调用?

纯对话模型有一个天然限制:它只能生成文本,不能执行动作。如果你问它“北京今天天气怎么样”,它只能根据训练数据“回忆”答案,无法获取实时信息。

工具调用(Function Calling / Tool Use)解决了这个问题:

用户: 北京今天天气怎么样?
  ↓
大模型: 我需要调用 get-weather 工具,参数 city="北京"
  ↓
后端: 执行 WeatherService.GetWeather("北京")
  ↓
后端: 返回 { "city": "北京", "temperature": "30℃" }
  ↓
大模型: 北京今天气温 30℃,天气晴朗...

整个流程对前端透明,用户感受到的是“大模型会查天气了”。


二、整体架构

在现有项目基础上,我们新增以下文件:

Services/IToolRegistry.cs          -> 工具注册表接口
Services/ToolRegistry.cs           -> 工具注册表实现
Services/IWeatherService.cs         -> 天气服务接口
Services/WeatherService.cs          -> 天气服务实现
Models/LlmTool.cs                  -> 工具定义模型
Models/LlmFunction.cs              -> 函数定义模型
Models/ToolResult.cs               -> 工具执行结果
Extensions/ToolExtensions.cs       -> 工具注册扩展方法

调用链路变为:

前端 -> BigModelController.SendMsg
    -> ChatStreamAsync 发送 messages + tools
    -> 大模型返回 tool_call
    -> ToolRegistry.ExecuteAsync 执行工具
    -> 把 tool 结果作为 assistant/tool 消息回传
    -> 大模型生成最终回答
    -> SSE 流式返回前端

三、定义工具模型

大模型的工具调用遵循 OpenAI 兼容格式,我们需要三个模型:

3.1 LlmTool

// Models/LlmTool.cs
public class LlmTool
{
    /// <summary>
    /// 工具类型,如 "function"
    /// </summary>
    public string Type { get; set; } = "function";

    /// <summary>
    /// 函数定义
    /// </summary>
    public LlmFunction Function { get; set; } = new();
}

3.2 LlmFunction

// Models/LlmFunction.cs
public class LlmFunction
{
    /// <summary>
    /// 函数名称
    /// </summary>
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 函数描述
    /// </summary>
    public string Description { get; set; } = string.Empty;

    /// <summary>
    /// 函数参数(JSON Schema)
    /// </summary>
    public object Parameters { get; set; }
}

3.3 ToolResult

// Models/ToolResult.cs
public class ToolResult
{
    /// <summary>
    /// 工具调用 ID
    /// </summary>
    public string ToolCallId { get; set; }

    /// <summary>
    /// 执行结果内容
    /// </summary>
    public string Content { get; set; }

    /// <summary>
    /// 工具名称
    /// </summary>
    public string ToolName { get; set; }
}

这三个模型构成了工具调用的“数据结构层”。


四、实现工具注册表

工具注册表(ToolRegistry)负责两件事:

  1. 注册工具:把工具名称、描述、参数和执行函数绑定在一起;
  2. 执行工具:根据大模型返回的工具名和参数,调用对应的 C# 方法。
// Services/IToolRegistry.cs
public interface IToolRegistry
{
    void Register(string name, string description, object parameters, 
        Func<IServiceProvider, string, CancellationToken, Task<string>> executor);

    List<LlmTool> GetAllTools();

    bool TryGetTool(string name, out LlmTool? tool);

    Task<ToolResult> ExecuteAsync(IServiceProvider serviceProvider, string toolCallId, 
        string toolName, string argumentsJson, CancellationToken cancellationToken = default);
}
// Services/ToolRegistry.cs
public class ToolRegistry : IToolRegistry
{
    private readonly ConcurrentDictionary<string, 
        (LlmTool Tool, Func<IServiceProvider, string, CancellationToken, Task<string>> Executor)> _tools = new();

    public void Register(string name, string description, object parameters, 
        Func<IServiceProvider, string, CancellationToken, Task<string>> executor)
    {
        _tools[name] = (
            Tool: new LlmTool
            {
                Function = new LlmFunction
                {
                    Name = name,
                    Description = description,
                    Parameters = parameters
                }
            },
            Executor: executor
        );
    }

    public List<LlmTool> GetAllTools()
    {
        return _tools.Values.Select(v => v.Tool).ToList();
    }

    public async Task<ToolResult> ExecuteAsync(IServiceProvider sp, string toolCallId, 
        string toolName, string argumentsJson, CancellationToken cancellationToken = default)
    {
        if (!_tools.TryGetValue(toolName, out var value))
        {
            return new ToolResult
            {
                ToolCallId = string.Empty,
                Content = $"未找到工具: {toolName}"
            };
        }

        var toolResult = new ToolResult
        {
            ToolCallId = toolCallId,
            ToolName = toolName
        };

        try
        {
            var result = await value.Executor(sp, argumentsJson, cancellationToken);
            toolResult.Content = result;
        }
        catch (Exception ex)
        {
            toolResult.Content = $"执行失败: {ex.Message}";
        }

        return toolResult;
    }
}

关键设计点:

  • 使用 ConcurrentDictionary 保证线程安全;
  • 每个工具存储两部分:描述信息(发给大模型)和 执行函数(本地调用);
  • GetAllTools() 用于在每次请求时把工具列表发给大模型;
  • ExecuteAsync 接收 argumentsJson,由具体 executor 解析参数并执行。

五、实现一个天气工具

我们以天气查询为例,展示如何实现一个具体工具。

5.1 定义服务接口

// Services/IWeatherService.cs
public interface IWeatherService
{
    Task<WeatherInfo> GetWeather(string city);
}

5.2 实现服务

// Services/WeatherService.cs
public class WeatherService : IWeatherService
{
    public Task<WeatherInfo> GetWeather(string city)
    {
        // 实际项目中这里调用第三方天气 API
        var weatherInfo = new WeatherInfo
        {
            City = "北京",
            Temperature = "30℃"
        };

        return Task.FromResult(weatherInfo);
    }
}

这里为了演示方便返回了硬编码数据。实际使用时,替换为真实天气 API 调用即可。

5.3 注册工具

// Extensions/ToolExtensions.cs
public static class ToolExtensions
{
    public static void AddTools(this WebApplicationBuilder builder)
    {
        // 注册天气服务
        builder.Services.AddScoped<IWeatherService, WeatherService>();

        // 注册工具
        builder.Services.AddScoped<IToolRegistry, ToolRegistry>(sp =>
        {
            var registry = new ToolRegistry();

            registry.Register(
                name: "get-weather",
                description: "获取天气",
                parameters: new
                {
                    type = "object",
                    properties = new
                    {
                        city = new { type = "string", description = "城市" }
                    },
                    required = new[] { "city" }
                },
                executor: async (sp, argumentsJson, cancellationToken) =>
                {
                    var dataService = sp.GetRequiredService<IWeatherService>();
                    var dataInfo = await dataService.GetWeather(argumentsJson);
                    if (dataInfo == null) return "{}";

                    return System.Text.Json.JsonSerializer.Serialize(dataInfo);
                }
            );

            return registry;
        });
    }
}

Program.cs 中启用:

// builder.AddTools();

取消注释后,工具系统就会在启动时注册到 DI 容器。


六、改造请求模型:支持 tools 字段

大模型的 /v1/chat/completions 接口支持在请求体中传入 tools 字段,告诉模型“你有这些工具可用”。

我们需要扩展 LlmRequest

// Models/LlmRequest.cs
public class LlmRequest
{
    [JsonPropertyName("model")]
    public string Model { get; set; }

    [JsonPropertyName("stream")]
    public bool Stream { get; set; }

    [JsonPropertyName("messages")]
    public List<LlmMessageRequest> Messages { get; set; } = [];

    [JsonPropertyName("tools")]
    public List<LlmTool>? Tools { get; set; }
}

同时,消息模型也需要支持 tool_callstool_call_id

public class LlmMessageRequest
{
    [JsonPropertyName("role")]
    public string Role { get; set; }

    [JsonPropertyName("content")]
    public string? Content { get; set; }

    [JsonPropertyName("tool_calls")]
    public List<LlmToolCall>? ToolCalls { get; set; }

    [JsonPropertyName("tool_call_id")]
    public string? ToolCallId { get; set; }
}

public class LlmToolCall
{
    [JsonPropertyName("id")]
    public string Id { get; set; } = string.Empty;

    [JsonPropertyName("type")]
    public string Type { get; set; } = "function";

    [JsonPropertyName("function")]
    public LlmToolCallFunction? Function { get; set; }
}

public class LlmToolCallFunction
{
    [JsonPropertyName("name")]
    public string Name { get; set; } = string.Empty;

    [JsonPropertyName("arguments")]
    public string Arguments { get; set; } = string.Empty;
}

七、改造流式响应模型

流式响应中,大模型可能会返回 tool_calls 而不是 content。我们需要扩展 OpenAiChatStreamResponse

// Models/OpenAiChatStreamResponse.cs
public class OpenAiChoice
{
    [JsonPropertyName("delta")]
    public OpenAiMessageContent? Delta { get; set; }

    [JsonPropertyName("finish_reason")]
    public string? FinishReason { get; set; }

    [JsonPropertyName("tool_calls")]
    public List<LlmToolCall>? ToolCalls { get; set; }
}

流式场景下,tool_calls 会分多个 chunk 到达:

chunk 1: tool_calls[0].function.name = "get-weather"
chunk 2: tool_calls[0].function.arguments = "{\"city\":\"北京\"}"

我们需要在 ChatStreamAsync 中累积这些片段。


八、核心改造:ChatStreamAsync 支持工具调用

这是本篇最核心的部分。我们需要在流式读取过程中:

  1. 检测是否有 tool_calls
  2. 累积工具名称和参数;
  3. 当工具调用完整后,执行工具;
  4. 把工具结果作为新消息回传给大模型;
  5. 继续读取大模型的最终回答。

下面给出完整的 ChatStreamAsync 实现:

private async IAsyncEnumerable<string> ChatStreamAsync(string userMessage, [EnumeratorCancellation] CancellationToken cancellation = default)
{
    var request = new LlmRequest
    {
        Model = _config.Model,
        Stream = true,
        Messages = new List<LlmMessageRequest>
        {
            new LlmMessageRequest
            {
                Role = "system",
                Content = "你是一个生活小助手,你可以帮我处理一些生活中的问题。"
            },
            new LlmMessageRequest
            {
                Role = "user",
                Content = userMessage
            }
        }
    };

    # region 添加工具定义
    var allTools = _toolRegistry.GetAllTools();
    request.Tools = allTools.Select(t => new
    {
        type = t.Type,
        function = new
        {
            name = t.Function.Name,
            description = t.Function.Description,
            parameters = t.Function.Parameters
        }
    }).ToArray();
    #endregion

    int turn = 0;
    while (!cancellation.IsCancellationRequested)   // 新增一个while循环,退出条件:没有工具调用
    {
        _logger.LogWarning("第 {Turn} 轮对话开始-------------------", ++turn);
        using var stream = await _lmApi.SendMessageStreamAsync(request, cancellation);

        using var reader = new System.IO.StreamReader(stream);
        var toolResults = new List<ToolResult>();

        OpenAiFunctionCall toolCallFunc = null;
        var tool_call_id = "";

        List<string> assistantContents = new List<string>();

        while (!cancellation.IsCancellationRequested) // 读取流式响应,退出条件:流式响应结束,[DONE]标记
        {
            string? line;
            try
            {
                // 读取每一行
                line = await reader.ReadLineAsync(cancellation);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "读取 LLM 流式响应异常");
                break;
            }
            if (line == null) break;
            if (string.IsNullOrWhiteSpace(line)) continue;
            if (!line.StartsWith("data: ")) continue;

            var data = line["data: ".Length..];
            if (data == "[DONE]") break; // 流式响应结束

            OpenAiChatStreamResponse? chunk = null;

            try
            {
                chunk = System.Text.Json.JsonSerializer.Deserialize<OpenAiChatStreamResponse>(data);
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "解析流式 chunk 失败: {Data}", data);
                continue;
            }

            // 解析内容
            var choice = chunk?.Choices?.FirstOrDefault();
            var delta = choice?.Delta;
            if (delta != null)
            {
                var thinkContent = string.Empty;
                if (!string.IsNullOrWhiteSpace(delta.Content))
                {
                    thinkContent = delta.Content;
                }
                else if (!string.IsNullOrWhiteSpace(delta.Reasoning))
                {
                    thinkContent = delta.Reasoning;
                }
                else if (!string.IsNullOrWhiteSpace(delta.ReasoningContent))
                {
                    thinkContent = delta.ReasoningContent;
                }

                if (!string.IsNullOrWhiteSpace(thinkContent))
                {
                    Console.Write(thinkContent);
                    assistantContents.Add(thinkContent);
                    yield return thinkContent;
                }

                #region  工具的处理

                // 大模型一次可能会请求多个模型
                if (delta.ToolCalls != null && delta.ToolCalls.Any())
                {
                    if (toolCallFunc == null)   //第一次发现要调用工具
                    {
                        tool_call_id = delta.ToolCalls[0].Id;
                        toolCallFunc = delta.ToolCalls[0].Function;
                    }
                    else
                    {
                        toolCallFunc.Arguments += delta.ToolCalls[0].Function.Arguments;    //由于stream不会输出完整内容,这里追加字符串
                    }
                }

                //检测工具参数是否完整
                if (toolCallFunc != null && IsValidJson(toolCallFunc.Arguments))
                {
                    var toolResult = await ExecuteToolAsync(tool_call_id, toolCallFunc.Name, toolCallFunc.Arguments, cancellation);
                    toolResults.Add(toolResult);

                    //重置toolCallFunc和tool_call_id,接收下一个可能的tool
                    toolCallFunc = null;
                    tool_call_id = "";

                }
                #endregion
            }
        }

        // 追加 assistant 消息到消息列表
        if (assistantContents.Any())
        {                   
            request.Messages.Add(new LlmMessageRequest
            {
                Role = "assistant",
                Content = string.Join("", assistantContents)
            });
        }

        // 如果有工具结果,追加 tool 消息并继续下一轮
        if (toolResults.Any())
        {
            var toolMessages = toolResults.Select(tr => new LlmMessageRequest
            {
                Content = tr.Content,
                ToolCallId = tr.ToolCallId,
                Role = "tool"
            });

            request.Messages.AddRange(toolMessages); // 更新 payload,继续下一轮循环

            _logger.LogWarning("第 {Turn} 轮对话结束-------------------", turn);
        }
        else
        {
            _logger.LogWarning("第 {Turn} 轮对话结束-------------------", turn);
            break; // 没有工具调用,结束
        }
    }

}

运行结果如下:

运行结果


九、关键流程解析

9.1 工具调用的流式累积

大模型在流式返回时,tool_calls 不是一次性返回的,而是分多个 chunk:

chunk 1: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"get-weather"}}]}}]}
chunk 2: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city"}}]}}]}
chunk 3: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"北京\"}"}}]}}]}

因此我们需要:

  • 累积参数,并始终验证是否完整的json字符串;
  • 当流结束后,拼接出完整的 argumentsJson

9.2 多轮对话上下文

工具调用不是一次性的。执行完工具后,我们需要:

  1. 把 assistant 的 tool_calls 消息加入上下文;
  2. 把 tool 的执行结果作为 role: tool 消息加入上下文;
  3. 再次请求大模型,让它基于工具结果生成最终回答。

这就是为什么代码中新增了一个while循环——因为可能连续调用多个工具。

9.3 消息格式示例

完整的消息序列如下:

[
  { "role": "system", "content": "你是一个有用的助手。" },
  { "role": "user", "content": "北京今天天气怎么样?" },
  { 
    "role": "assistant",
    "tool_calls": [
      {
        "id": "call_123",
        "type": "function",
        "function": {
          "name": "get-weather",
          "arguments": "{\"city\":\"北京\"}"
        }
      }
    ]
  },
  { "role": "tool", "tool_call_id": "call_123", "content": "{\"city\":\"北京\",\"temperature\":\"30℃\"}" }
]

大模型看到这条 tool 消息后,就会生成类似“北京今天气温 30℃”的自然语言回答。


十、完整流程回顾

用户: 北京今天天气怎么样?
  ↓
1. Controller 接收请求
2. 构建 messages + tools 列表
3. 调用大模型 /v1/chat/completions
  ↓
4. 大模型返回 tool_calls(流式)
5. 后端累积 tool_calls
6. 调用 ToolRegistry.ExecuteAsync
7. WeatherService.GetWeather("北京") 返回结果
8. 把结果作为 tool 消息回传
9. 再次调用大模型
  ↓
10. 大模型生成: "北京今天气温 30℃,天气晴朗..."
11. SSE 流式返回前端

十一、常见问题

11.1 工具调用失败怎么办?

ToolRegistry.ExecuteAsync 已经做了异常捕获:

catch (Exception ex)
{
    toolResult.Content = $"执行失败: {ex.Message}";
}

失败结果会作为 tool 消息回传给大模型,大模型会根据失败原因调整回答。

11.2 支持多个工具吗?

完全支持。ToolRegistry 使用 ConcurrentDictionary 存储多个工具,GetAllTools() 返回全部工具定义。大模型会根据用户问题自动选择最合适的工具。

11.3 工具参数解析有库吗?

示例中我们直接传递 argumentsJson 给 executor,由 executor 自行解析。实际项目中可以使用 System.Text.Json 反序列化为强类型对象:

executor: async (sp, argumentsJson, ct) =>
{
    var args = JsonSerializer.Deserialize<WeatherArgs>(argumentsJson);
    var result = await weatherService.GetWeather(args.City);
    return JsonSerializer.Serialize(result);
}

十二、总结

本文我们为大模型增加了一个天气查询工具,实现了完整的 Function Calling 闭环:

  • LlmTool / LlmFunction 定义工具结构;
  • ToolRegistry 注册和管理工具;
  • 在流式响应中检测并累积 tool_calls
  • 执行本地工具并把结果回传给大模型;
  • 大模型基于工具结果生成最终回答。

这套工具系统是可扩展的:你可以继续注册“搜索工具”、“数据库查询工具”、“文件操作工具”等等,大模型会根据需要自动调用。


十四、预告:下一篇

第三篇:流式消息中断恢复

我们将解决以下问题:

  • 前端网络中断后,如何恢复 SSE 连接?
  • 后端如何保存流式生成过程中的中间状态?
  • 如何实现“断点续传”,避免重复生成?
  • 结合 Redis / 数据库实现分布式会话恢复。

posted @ 2026-08-07 09:53  scotly  阅读(2)  评论(0)    收藏  举报