.NET Core 与大模型交互实战(二):增加一个工具
这是《.NET Core 与大模型交互》系列的第二篇。第一篇我们实现了基础对话与 SSE 流式输出;本篇将在其基础上,为大模型增加一个“天气查询”工具,实现 Function Calling / Tool Use 的完整闭环。
系列大纲
本系列共三篇,围绕同一个 .NET Web API 项目逐步演进:
-
基本对话与流式响应
搭建 .NET 后端与大模型的最小交互链路,实现 SSE 流式输出,让前端可以“边生成边显示”。 -
增加一个工具
在流式对话中引入工具调用能力,让大模型在需要时调用本地服务(如天气查询),再把结果回传给模型继续生成。 -
流式消息中断恢复
解决流式传输中的断连、重连问题,实现消息中断后的状态恢复与续写,提升生产环境可用性。
一、为什么需要工具调用?
纯对话模型有一个天然限制:它只能生成文本,不能执行动作。如果你问它“北京今天天气怎么样”,它只能根据训练数据“回忆”答案,无法获取实时信息。
工具调用(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)负责两件事:
- 注册工具:把工具名称、描述、参数和执行函数绑定在一起;
- 执行工具:根据大模型返回的工具名和参数,调用对应的 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_calls 和 tool_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 支持工具调用
这是本篇最核心的部分。我们需要在流式读取过程中:
- 检测是否有
tool_calls; - 累积工具名称和参数;
- 当工具调用完整后,执行工具;
- 把工具结果作为新消息回传给大模型;
- 继续读取大模型的最终回答。
下面给出完整的 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 多轮对话上下文
工具调用不是一次性的。执行完工具后,我们需要:
- 把 assistant 的
tool_calls消息加入上下文; - 把 tool 的执行结果作为
role: tool消息加入上下文; - 再次请求大模型,让它基于工具结果生成最终回答。
这就是为什么代码中新增了一个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 / 数据库实现分布式会话恢复。

浙公网安备 33010602011771号