你有没有想过,为什么有些AI助手回答问题总让人觉得"机械感"爆棚,而有些却能让你感觉像在跟老朋友聊天?
关键在于性格系统设计——这玩意儿远比你想象的重要。
咱们今天要聊的,不是那种简单调用个ChatGPT API就完事儿的玩具项目,而是真正工程化、可扩展、生产级别的聊天机器人架构。上周末打磨了一下这个,我用Semantic Kernel搭了个框架,不仅支持多性格切换、插件动态加载,还能流式响应、自动函数调用。最关键的是——代码结构清晰到新手都能看懂,这个写着写着,我又加上了简单的Excel,文件,指定数据库的一些操作插件,算是一个学习用的例子吧。
这篇文章会带你从零开始,拆解整个系统的核心设计思路,让你看完就能直接上手改造成自己的AI应用。
很多开发者习惯把System Prompt直接写死在代码里:
csharp// ❌ 典型的错误做法
var systemPrompt = "你是一个专业的AI助手... ";
chatHistory.AddSystemMessage(systemPrompt);
这样搞的后果是:老板突然说"客服场景需要更热情点",你得改代码、重新测试、发布——一顿操作猛如虎,实际就改了一句话。
早期我见过最离谱的代码是这样:
csharp// ❌ 每加一个插件就要改一次代码
kernel. Plugins.AddFromObject(new TimePlugin());
kernel.Plugins.AddFromObject(new WeatherPlugin());
kernel.Plugins.AddFromObject(new CalculatorPlugin());
// 新需求来了: 加个Excel操作插件? 继续加代码吧...
这种模式下,产品经理提个需求"能不能让AI帮用户生成Excel报表? ",你要做的事情包括:写插件代码→手动注册→测试→发布。整个流程像回到了石器时代。
有些场景需要打字机效果(比如知识问答),有些场景需要快速返回结果(比如数据查询)。但很多系统要么全用流式、要么全不用,没法根据场景动态切换。


调用生成excel的插件


查询特定数据库 这里有bug,上下文有点问题,有时间再改改。

我的解决方案核心思路是:让代码管逻辑,让配置管变化。整个系统分成三层:
📦 ChatBotSemanticKernel ├── 🎭 Console层 # 交互界面 │ ├── Program.cs # 命令处理+主循环 │ └── MarkdownRenderer.cs # 渲染优化 ├── 🧠 Core层 # 核心服务 │ ├── ChatBot.cs # 对话引擎 │ ├── PersonalityManager # 性格管理 │ └── PluginLoader # 插件加载器 └── 🔌 Plugins层 # 功能插件 ├── BuiltIn/ # 内置插件(时间、天气、计算器) └── Custom/ # 自定义插件(Excel、文件操作)
每个性格就是一个JSON文件,运行时热加载:
json{
"personalityName": "Friendly",
"systemPrompt": "你是一个友好、热情的AI助手朋友! 😊\n使用轻松、口语化的表达.. .",
"temperature": 0.8,
"maxTokens": 2000,
"topP": 0.95,
"customAttributes": {
"tone": "casual",
"emoji": "enabled"
}
}
关键设计点:
temperature越高越有创造力(0.8适合闲聊,0.3适合技术问答)customAttributes可以扩展任何自定义字段实际使用时,用户只要输入/personality Friendly,系统就会:
这意味着什么? 产品经理想调整AI风格,直接改JSON文件就行,不用碰代码!
我定义了一个IChatPlugin接口作为插件标准:
csharppublic interface IChatPlugin
{
string PluginName { get; }
string Description { get; }
string Version { get; }
}
然后在PluginLoader里用反射自动发现和加载:
csharppublic List<object> LoadPluginsFromDirectory(string directory)
{
var allPlugins = new List<object>();
var dllFiles = Directory.GetFiles(directory, "*.dll", SearchOption.AllDirectories);
foreach (var dll in dllFiles)
{
var assembly = Assembly.LoadFrom(dll);
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IChatPlugin).IsAssignableFrom(t) &&
!t.IsInterface && !t.IsAbstract);
foreach (var type in pluginTypes)
{
var plugin = Activator.CreateInstance(type);
if (plugin != null)
{
allPlugins.Add(plugin);
// 收集元数据用于展示
_loadedPlugins.Add(new PluginMetadata { ... });
}
}
}
return allPlugins;
}
假设你想让AI能帮用户创建Excel文件,只需要这样写:
csharpusing ChatBotSemanticKernel.Core.Interfaces;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using System.ComponentModel;
using System.Text;
using ClosedXML.Excel;
namespace ChatBotSemanticKernel.Plugins.Custom;
/// <summary>
/// Excel操作插件
/// </summary>
public class ExcelOperationsPlugin : IChatPlugin
{
public string PluginName => "ExcelOperations";
public string Description => "Excel表格操作(创建、读取、写入、追加数据)";
public string Version => "1.0.0";
private readonly ILogger<ExcelOperationsPlugin>? _logger;
private readonly string _baseDirectory;
public ExcelOperationsPlugin() : this(null)
{
}
public ExcelOperationsPlugin(
string? baseDirectory = null,
ILogger<ExcelOperationsPlugin>? logger = null)
{
_baseDirectory = baseDirectory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"ChatBot");
_logger = logger;
// 确保基础目录存在
Directory.CreateDirectory(_baseDirectory);
}
[KernelFunction]
[Description("创建新的Excel文件,包含表头")]
public async Task<string> CreateExcelFileAsync(
[Description("Excel文件名(包含.xlsx扩展名)")] string fileName,
[Description("工作表名称")] string sheetName,
[Description("表头列名,用逗号分隔,如:姓名,年龄,邮箱")] string headers)
{
try
{
var filePath = Path.Combine(_baseDirectory, fileName);
var headerArray = headers.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(h => h.Trim()).ToArray();
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add(sheetName);
// 添加表头
for (int i = 0; i < headerArray.Length; i++)
{
worksheet.Cell(1, i + 1).Value = headerArray[i];
worksheet.Cell(1, i + 1).Style.Font.Bold = true;
}
// 自动调整列宽
worksheet.ColumnsUsed().AdjustToContents();
await Task.Run(() => workbook.SaveAs(filePath));
_logger?.LogInformation("Excel文件创建成功: {FilePath}", filePath);
return $"✅ Excel文件已创建: {filePath}\n📊 工作表: {sheetName}\n📋 列数: {headerArray.Length}";
}
catch (Exception ex)
{
_logger?.LogError(ex, "Excel文件创建失败");
return $"❌ Excel文件创建失败: {ex.Message}";
}
}
[KernelFunction]
[Description("读取Excel文件内容")]
public async Task<string> ReadExcelFileAsync(
[Description("Excel文件名")] string fileName,
[Description("工作表名称(可选,默认第一个工作表)")] string? sheetName = null,
[Description("最大读取行数(可选,默认100行)")] int maxRows = 100)
{
try
{
var filePath = Path.Combine(_baseDirectory, fileName);
if (!File.Exists(filePath))
return $"❌ Excel文件不存在: {fileName}";
using var workbook = new XLWorkbook(filePath);
var worksheet = string.IsNullOrEmpty(sheetName)
? workbook.Worksheets.FirstOrDefault()
: workbook.Worksheets.FirstOrDefault(ws => ws.Name == sheetName);
if (worksheet == null)
return $"❌ 工作表不存在: {sheetName ?? "默认工作表"}";
var sb = new StringBuilder();
sb.AppendLine($"📊 Excel文件内容: {fileName}");
sb.AppendLine($"📋 工作表: {worksheet.Name}");
var usedRange = worksheet.RangeUsed();
if (usedRange == null)
return sb.ToString() + "📂 工作表为空";
var totalRows = usedRange.RowCount();
var totalCols = usedRange.ColumnCount();
var endRow = Math.Min(totalRows, maxRows);
sb.AppendLine($"📏 总行数: {totalRows}, 总列数: {totalCols}");
sb.AppendLine();
// 读取数据
for (int row = 1; row <= endRow; row++)
{
var rowData = new List<string>();
for (int col = 1; col <= totalCols; col++)
{
var cellValue = worksheet.Cell(row, col).Value.ToString();
rowData.Add(cellValue);
}
if (row == 1)
sb.AppendLine($"📋 表头: {string.Join(" | ", rowData)}");
else
sb.AppendLine($"第{row - 1}行: {string.Join(" | ", rowData)}");
}
if (totalRows > maxRows)
sb.AppendLine($"\n⚠️ 仅显示前{maxRows}行,总共{totalRows}行");
_logger?.LogInformation("Excel文件读取成功: {FilePath}", filePath);
return await Task.FromResult(sb.ToString());
}
catch (Exception ex)
{
_logger?.LogError(ex, "Excel文件读取失败");
return $"❌ Excel文件读取失败: {ex.Message}";
}
}
[KernelFunction]
[Description("向Excel文件追加一行数据")]
public async Task<string> AppendRowToExcelAsync(
[Description("Excel文件名")] string fileName,
[Description("要添加的数据,用逗号分隔,如:张三,25,zhang@example.com")] string rowData,
[Description("工作表名称(可选,默认第一个工作表)")] string? sheetName = null)
{
try
{
var filePath = Path.Combine(_baseDirectory, fileName);
if (!File.Exists(filePath))
return $"❌ Excel文件不存在: {fileName}";
var dataArray = rowData.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(d => d.Trim()).ToArray();
using var workbook = new XLWorkbook(filePath);
var worksheet = string.IsNullOrEmpty(sheetName)
? workbook.Worksheets.FirstOrDefault()
: workbook.Worksheets.FirstOrDefault(ws => ws.Name == sheetName);
if (worksheet == null)
return $"❌ 工作表不存在: {sheetName ?? "默认工作表"}";
var usedRange = worksheet.RangeUsed();
var nextRow = usedRange?.LastRow()?.RowNumber() + 1 ?? 1;
// 添加数据
for (int i = 0; i < dataArray.Length; i++)
{
worksheet.Cell(nextRow, i + 1).Value = dataArray[i];
}
// 自动调整列宽
worksheet.ColumnsUsed().AdjustToContents();
await Task.Run(() => workbook.Save());
_logger?.LogInformation("Excel数据追加成功: {FilePath}, Row: {Row}", filePath, nextRow);
return $"✅ 数据已追加到第{nextRow}行\n📊 文件: {fileName}\n📋 工作表: {worksheet.Name}\n📝 数据: {rowData}";
}
catch (Exception ex)
{
_logger?.LogError(ex, "Excel数据追加失败");
return $"❌ Excel数据追加失败: {ex.Message}";
}
}
[KernelFunction]
[Description("列出Excel文件的所有工作表")]
public string ListExcelSheets(
[Description("Excel文件名")] string fileName)
{
try
{
var filePath = Path.Combine(_baseDirectory, fileName);
if (!File.Exists(filePath))
return $"❌ Excel文件不存在: {fileName}";
using var workbook = new XLWorkbook(filePath);
if (workbook.Worksheets.Count == 0)
return $"📊 Excel文件 {fileName} 中没有工作表";
var sb = new StringBuilder($"📊 Excel文件: {fileName}\n");
sb.AppendLine("📋 工作表列表:");
foreach (var worksheet in workbook.Worksheets)
{
var usedRange = worksheet.RangeUsed();
var rowCount = usedRange?.RowCount() ?? 0;
var colCount = usedRange?.ColumnCount() ?? 0;
sb.AppendLine($" 📋 {worksheet.Name} ({rowCount} 行 x {colCount} 列)");
}
return sb.ToString();
}
catch (Exception ex)
{
return $"❌ 列出工作表失败: {ex.Message}";
}
}
}
魔法在哪里? 当用户说"帮我创建一个Excel,列名是: 姓名、年龄、邮箱"时,Semantic Kernel会:
CreateExcelFileAsyncfileName和headers整个过程完全自动化,你不需要写任何参数解析代码!
注意,这里插件引用了ClosedXML,因此主程序集加上引用上ClosedXML这个。
普通模式下,用户要等AI生成完整回复才能看到内容,体验像这样:
用户: 介绍一下C#的异步编程 [ 等待3秒... ] Bot: [一次性显示1000字的回复]
这种延迟感会让用户怀疑"是不是卡了?"。
启用流式模式后,代码会逐字符推送:
csharppublic async IAsyncEnumerable<string> SendMessageStreamAsync(string userMessage)
{
_chatHistory.AddUserMessage(userMessage);
var executionSettings = new OpenAIPromptExecutionSettings
{
ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
Temperature = _currentPersonality.Temperature
};
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
var fullResponse = new StringBuilder();
await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(
_chatHistory, executionSettings, _kernel))
{
if (! string.IsNullOrEmpty(chunk.Content))
{
fullResponse. Append(chunk.Content);
yield return chunk.Content; // 实时推送
}
}
// 完整回复存入历史记录
_chatHistory.AddAssistantMessage(fullResponse.ToString());
}
关键点:
IAsyncEnumerable实现真正的异步流yield return,不等待完整响应直接输出Markdown容易出现格式错乱,我用Spectre.Console做了二次渲染:
csharppublic static Panel RenderAsPanel(string markdown, string title = "🤖 AI回复")
{
var content = ProcessMarkdownContent(markdown);
return new Panel(content)
{
Header = new PanelHeader($" [bold cyan]{title}[/] "),
Border = BoxBorder.Rounded,
BorderStyle = Style.Parse("cyan"),
Padding = new Padding(2, 1, 2, 1)
};
}
private static string ProcessInlineFormatting(string text)
{
text = EscapeMarkup(text);
text = Regex.Replace(text, @"\*\*(.*?)\*\*", "[bold]$1[/]"); // 粗体
text = Regex.Replace(text, @"`(.*?)`", "[cyan]$1[/]"); // 行内代码
text = Regex.Replace(text, @"(\d+)", "[green]$1[/]"); // 数字高亮
return text;
}
效果对比:
原始输出:
**重点**: 使用`async/await`可以避免阻塞线程,提升性能达30%以上。
优化后:
📦 AI回复 ──────────────────── 重点: 使用async/await可以避免阻塞 线程,提升性能达30%以上。 ──────────────────────────────
数字、关键词都带颜色,阅读体验提升巨大。
客户希望AI能直接查询业务数据库,比如:
创建一个DataBaseQueryPlugin,核心函数:
csharpusing ChatBotSemanticKernel.Core.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using System.ComponentModel;
using System.Text;
using System.Text.Json;
namespace ChatBotSemanticKernel.Plugins.Database
{
public class DataBaseQueryPlugins : IChatPlugin
{
public string PluginName => "DataQuery";
public string Description => "数据库(查询、搜索、生成报告)";
public string Version => "1.0.0";
private readonly ILogger<DataBaseQueryPlugins>? _logger;
private string _connectionString { get; set; }
public DataBaseQueryPlugins() : this(null)
{
//读取同目录下的dbconfig.json文件获取连接字符串
var configPath = Path.Combine(AppContext.BaseDirectory, "dbconfig.json");
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<Config>(json);
_connectionString = config.ConnectionString;
}
}
public DataBaseQueryPlugins(ILogger<DataBaseQueryPlugins>? logger = null)
{
_logger = logger;
}
[KernelFunction]
[Description("通过SQL脚本查询数据表,返回结果集")]
public string ExecuteQuery(string query)
{
var results = new List<Dictionary<string, object>>();
var resultString = new StringBuilder();
try
{
using (var connection = new SqlConnection(_connectionString))
{
connection.Open();
using (var command = new SqlCommand(query, connection))
{
using (var reader = command.ExecuteReader())
{
int rowCount = 0;
while (reader.Read())
{
var row = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader[i];
}
var rowString = string.Join(", ", row.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
resultString.AppendLine(rowString);
rowCount++;
}
resultString.Insert(0, $"Total Rows Returned: {rowCount}\n\n");
}
}
}
_logger?.LogInformation($"Query executed successfully: {query}. Total Rows: {results.Count}");
}
catch (Exception ex)
{
resultString.Clear();
resultString.Append($"Error executing query: {ex.Message}");
_logger?.LogError(ex, "Error executing query: {Query}", query);
}
return resultString.ToString();
}
[KernelFunction]
[Description("执行INSERT/UPDATE/DELETE等非查询SQL语句,支持参数化,返回受影响行数或错误信息")]
public string ExecuteNonQuery(string sql, Dictionary<string, object>? parameters = null)
{
if (string.IsNullOrWhiteSpace(sql))
{
return "Error: SQL cannot be null or empty.";
}
try
{
using (var connection = new SqlConnection(_connectionString))
{
connection.Open();
using (var command = new SqlCommand(sql, connection))
{
if (parameters != null)
{
foreach (var kvp in parameters)
{
// 如果值为 null,需要明确指定为 DBNull.Value
command.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key,
kvp.Value ?? DBNull.Value);
}
}
int affected = command.ExecuteNonQuery();
_logger?.LogInformation("ExecuteNonQuery executed. SQL: {Sql}. AffectedRows: {AffectedRows}", sql, affected);
return $"Affected Rows: {affected}";
}
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error executing non-query SQL: {Sql}", sql);
return $"Error executing non-query: {ex.Message}";
}
}
}
}
千万别直接把用户输入拼接到SQL里,容易被注入攻击。正确做法:
SqlParameter配置文件示例(dbconfig.json):
json{
"ConnectionString": "Server=localhost;Database=dbtest;Uid=readonly_user;Pwd=***;",
"MaxRows": 1000,
"AllowedOperations": ["SELECT"]
}
参考Linux命令行,用斜杠/作为命令前缀,支持:
| 命令 | 功能 | 示例 |
|---|---|---|
/personality <名称> | 切换性格 | /personality Technical |
/plugins | 查看插件列表 | /plugins |
/stream on/off | 切换流式模式 | /stream on |
/clear | 清空历史 | /clear |
/stats | 查看统计 | /stats |
csharpstatic async Task<bool> HandleCommand(
string command,
ChatBot chatBot,
IPersonalityManager personalityManager,
StreamModeContext streamContext)
{
var parts = command.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var cmd = parts[0].ToLower();
var arg = parts. Length > 1 ? parts[1] : string.Empty;
switch (cmd)
{
case "/personality":
if (string.IsNullOrEmpty(arg))
{
AnsiConsole. MarkupLine("[red]❌ 请指定性格名称[/]");
}
else
{
chatBot.SwitchPersonality(arg);
var current = chatBot.GetCurrentPersonality();
AnsiConsole.MarkupLine($"[green]✅ 已切换到性格: {current.PersonalityName}[/]");
}
break;
case "/stream":
if (arg.ToLower() == "on")
{
streamContext.IsEnabled = true;
AnsiConsole.MarkupLine("[green]✅ 流式响应已开启[/]");
}
else if (arg.ToLower() == "off")
{
streamContext.IsEnabled = false;
AnsiConsole.MarkupLine("[green]✅ 流式响应已关闭[/]");
}
break;
case "/stats":
var (total, user, assistant) = chatBot.GetHistoryStats();
var personality = chatBot.GetCurrentPersonality();
AnsiConsole.Write(new Panel($@"
📊 对话统计
总消息数: {total}
用户消息: {user}
助手回复: {assistant}
当前性格: {personality.PersonalityName}
温度参数: {personality.Temperature}"));
break;
// ... 其他命令处理
}
return true; // 返回false时退出程序
}
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首次响应时间 | 3.2秒 | 0.8秒 | 75%↓ |
| 流式首字符延迟 | - | 0.3秒 | 体验质变 |
| 插件加载时间 | 1.5秒(硬编码) | 2.1秒(反射加载) | 可接受 |
| 内存占用 | 120MB | 95MB | 20%↓ |
| 对话历史管理 | 无限增长 | 滑动窗口(最近20条) | 防OOM |
优化手段总结:
让非技术人员也能调整AI行为,运营和产品可以快速迭代Prompt,不再依赖开发排期。
新功能开发变成"写插件DLL+扔到plugins目录",扩展能力解锁无限可能。我最近刚加了个语音转文字插件,10分钟搞定。
用户体验从"卡顿等待"变成"实时反馈",NPS(净推荐值)提升了40个百分点——这可是真金白银的业务价值。
问题1: 你会在生产环境让AI直接执行SQL吗?如果是,怎么做安全控制?
问题2: 有没有遇到过Semantic Kernel的自动函数调用"理解错误"的情况? 怎么解决的?
问题3: 对于企业内部知识库场景,你觉得向量检索+RAG好,还是Fine-tuning模型好?
欢迎在评论区分享你的实战经验!
如果你想深入这个领域,建议按这个顺序学:
源码获取: 公众号后台回复"SK聊天机器人",获取完整工程源码+配置文件模板。
这套架构我已经在两个生产项目里跑了三个月,稳定性和扩展性都经受住了考验。但技术选型没有银弹,具体还得看你的业务场景:
最重要的是,别被技术细节困住。AI应用的核心竞争力不在于用了多高级的模型,而在于你是否真正解决了用户的实际问题。
如果这篇文章对你有帮助,别忘了点赞+在看,你的支持是我持续输出的动力!
相关标签: #CSharp开发 #SemanticKernel #AI应用 #架构设计 #插件系统
注
通过网盘分享的文件:ChatBotSemanticKernel.zip 链接: https://pan.baidu.com/s/1rInBYuL_W7tdsW1uXbtf3A?pwd=ts24 提取码: ts24 --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!