很多人问我,AI Agent 平台到底该怎么起步。今天我决定开一个系列,把从零搭建一个 AI Agent 平台的过程完整记录下来,这是第一天的内容。
先说下背景:我准备基于 .NET 6 + C# 10 构建一个跨平台的 AI Agent 平台,核心目标有两个——一是跑通 Agent 的基本运行机制,二是为后面接入更多工具和技能(Skills)留好扩展点。这个平台不是要做一个类似 Coze 那样面向 C 端的完整产品,而是更偏底层、更偏自用的一套基础设施。如果你是做后端开发、对 Agent 内部机制感兴趣、或者想自己搭一套 Agent 服务的人,这篇应该能给你一些实际参考。
我会把 Day1 的决策过程、架构设计、核心代码、踩坑记录都写出来,这篇文章相当于一份完整的开发日志,不是那种只贴代码不给思路的教程。
1. 为什么 Day1 就要纠结“Agent 平台”而不是直接用现成的?
动手之前,我其实先回答了一个问题:“直接用 Coze、Dify 这类平台不就行了,为什么要自己搭一个?”
答案是:场景不同,选择的路径完全不同。如果你只是想要一个快速验证的 Demo,用现成平台完全没问题。但如果你想做的是“把 Agent 能力集成到自己的业务系统里”,情况就完全不同了——你要考虑数据怎么和内部系统打通、模型调用怎么统一管理、工具怎么按需加载和隔离、日志怎么追踪整个 Agent 的思考过程。这些东西,现成平台往往给不到你足够的灵活性,尤其是当你需要把 Agent 和自家业务深度耦合时,自己搭一套反而更可控。
这里不展开对比所有产品,直接总结几个我在 Day1 梳理后的判断标准:
- 你的核心资产是什么:如果用现成平台,核心资产在别人那里;如果自建,核心资产是数据、工具、流程沉淀,都归你。
- 灵活度需求:现成平台给你的是“积木”,但很多时候你需要的是“能捏成任意形状的泥巴”。
- 成本模型:现成平台按调用量收费,自建平台的模型费用当然也避不开,但省掉了平台抽成,长期规模大了更划算。
Day1 我选择的就是自建路线,但并不是说现成平台一无是处。恰恰相反,我仔细研究了几大平台的设计思路,把它们最核心的抽象概念借鉴了过来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型:C# 10 + .NET 6 跨平台方案的优势与代价
2.1 为什么选 .NET 6 + C# 10,而不是 Python 或 Node.js
选型这件事,一定是基于团队背景和业务约束的。我不是没考虑过 Python(毕竟 LangChain 生态在那里摆着),但我们的团队后端主力就是 C#,后续这平台要嵌入我们的 .NET 微服务体系中,为了一个 Agent 平台引入一整条 Python 技术栈,后续的运维成本、人力成本会成倍增加。
.NET 6 是微软的 LTS(长期支持)版本,官方支持周期延续挺久,生产环境用它的风险要比 .NET 7 这类 STS 版本低得多。项目今天立项,到正式上线怎么也得几个月,选 LTS 版本意味着不用在开发中途突然陪微软搞大版本升级。
跨平台这件事就更实际了。我们的生产服务器有 Linux 和 Windows 两种,开发机又有 Mac 和 Windows 混合。.NET 6 天生跨平台,还支持单文件发布,解决了“开发环境一个样、生产环境另一个样”的经典痛点。热词里提到“C# 10 和 .NET 6 代码跨平台开发”,这确实不是虚的。
2.2 C# 10 的几个新语法,在 Agent 开发里确实有用
既然选了 C# 10,那就要把语言特性用起来。Agent 开发里最常用到的新语法有这几个:
- 全局 using:一个项目里只写一次
global using,所有文件都不需要重复引入命名空间,代码清爽很多。 - 文件级命名空间:去掉了一层大括号缩进,写了几天代码你就会发现这是真香。
- record 类型:特别适合定义 Agent 消息、工具调用的输入输出这些不可变数据结构。
- Lambda 的增强:可以给 Lambda 表达式加返回类型,在处理动态工具调用时很方便。
我在设计 Agent 运行时的消息结构时就用了 record 类型,这个后面代码部分会看到,对值的不可变性要求很契合。
2.3 注意事项:别为了新特性而新特性
这里得说一句实在话。C# 10 和 .NET 6 搭配确实顺手,但注意,不是所有第三方库都已跟进 .NET 6,尤其是某些偏门库。Day1 里我最担心的是向量数据库 SDK 的支持情况,事实证明担心不无道理,后面会讲这个坑。
3. AI Agent 平台的核心抽象:LLM 网关、运行时、工具注册中心
3.1 三个核心组件,缺一不可
看了不少 Agent 平台的架构(包括 Hugging Face 的一些 Agent 概念、Coze 的工具设计、微软 Semantic Kernel 的设计思路),我提取了一个足够通用的三层抽象:
第一层是 LLM 网关(LLM Gateway)。它负责和各家大模型 API 打交道。为什么要单独做一层?因为模型供应商太多了,有 OpenAI 系的、有国产大模型、还有开源的本地部署模型,它们的接口格式、鉴权方式、限流策略都不一样。没有这层网关,上层业务代码就要直接面对供应商差异,时间一长必然混乱。
第二层是 Agent 运行时(Agent Runtime)。它是整个平台的“大脑”,负责一个任务的完整生命周期:接收用户目标 → 规划步骤 → 决定调用什么工具 → 拿到工具返回结果 → 判断目标是否完成 → 没完成就继续规划下一步。这个循环也就是常说的 ReAct 模式。
第三层是 工具注册中心(Skill/Tool Registry)。Agent 不能只靠模型“嘴硬”,必须能真实操作外界——查数据库、调 API、读写文件、执行命令。每个能力就是一个工具,注册中心负责维护这些工具的元数据、入参出参定义、鉴权信息。
这三层的关系用一句话概括就是:用户把目标交给运行时,运行时基于模型做规划,规划出来的动作由工具注册中心去执行,执行结果再返还给运行时继续推理。 这是最基础但也是最重要的架构骨架。
3.2 Skills 和 Agent 的关系
热词里有人问“AI Skills 和 Agent 的区别”,这里也说一下我的理解。Skill 是“能力单元”,Agent 是“决策主体”。你有一个“查询天气”的 Skill,意味着你能提供这个功能,但由谁来决定“此刻该查天气了”?是 Agent。一个 Agent 可以挂多个 Skills,Skills 之间彼此独立、可插拔,这也是我把工具注册中心单独做成一个模块的原因。
这套设计带来的直接好处是:以后每加一个新能力,不需要改 Agent 运行时的代码,只要往注册中心塞一个新的 Skill 描述就可以了。
4. ReAct 循环的落地实现:C# 里写一个最小可用的 Agent Runner
4.1 约定模型接口:先定义一个模型无关的 Client
核心代码从模型调用这块开始。为了不被某一家模型供应商绑定,我为 LLM 网关定义了一个最薄的接口,然后实现一个用 HttpClient 调用 OpenAI 兼容接口的客户端。为什么是 OpenAI 兼容接口?因为现在大量国产模型、开源模型代理层都兼容 OpenAI 的 Chat Completion 格式,接一家等于接一片。
csharp复制public interface ILLMClient
{
Task<string> ChatAsync(
IReadOnlyList<ChatMessage> messages,
double temperature = 0.2,
CancellationToken ct = default);
}
public sealed record ChatMessage(
string Role,
string Content);
这里用了 record 定义 ChatMessage,Role 就是 system、user、assistant 那套,Content 是消息文本。这个接口设计得足够简单,先跑通再说。之后需要流式、需要 Json Mode、需要 Function Calling 的时候再扩展。
OpenAI 兼容客户端的实现也不复杂:
csharp复制public sealed class OpenAICompatibleClient : ILLMClient
{
private readonly HttpClient _httpClient;
private readonly string _apiKey;
private readonly string _model;
public OpenAICompatibleClient(
HttpClient httpClient,
string endpoint,
string apiKey,
string model)
{
_httpClient = httpClient;
_httpClient.BaseAddress = new Uri(endpoint);
_apiKey = apiKey;
_model = model;
}
public async Task<string> ChatAsync(
IReadOnlyList<ChatMessage> messages,
double temperature = 0.2,
CancellationToken ct = default)
{
var payload = new
{
model = _model,
temperature = temperature,
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToArray()
};
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
request.Content = JsonContent.Create(payload);
using var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<ChatCompletionResponse>(ct);
return json!.Choices[0].Message.Content;
}
}
你可能注意到了,我没有把 Complete 的 JSON 反序列化到强类型响应的完整对象里,只取了一个 ChatCompletionResponse 并只保留 Choices 数组。别觉得偷懒,Day1 的目标是先把链路跑通,需要流式输出和 Token 用量统计的时候再补。
4.2 定义工具接口和内置工具
再往下是工具层。我在 Day1 里做了一个最简单但也最实用的内置工具:DateTimeTool。它返回服务器当前时间。看起来有点“弱”?但作为第一个验证工具,它极度合适:没有外部依赖、不会出错、结果完全可预期。
关键的设计在于我现在就把“工具调用约定”定下来:
csharp复制public interface IAgentTool
{
string Name { get; }
string Description { get; }
Task<string> ExecuteAsync(string inputJson, CancellationToken ct);
}
然后实现:
csharp复制public sealed class DateTimeTool : IAgentTool
{
public string Name => "datetime";
public string Description => "获取当前日期和时间,无参数。";
public Task<string> ExecuteAsync(string inputJson, CancellationToken ct)
{
var now = DateTime.Now;
return Task.FromResult(now.ToString("yyyy-MM-dd HH:mm:ss"));
}
}
每个工具只需要实现三样东西:名字、描述、执行方法。为什么输入参数用 JSON 字符串而不是强类型对象?因为工具描述是给大模型看的,大模型生成的就是一串 JSON 字符串,到执行层再解析才能解耦。这也是大多数 Agent 框架的实际做法。
4.3 ReAct 循环:Agent 最核心的执行机制
接下来是重头戏:ReAct 循环。全称是 Reasoning + Acting。每一轮循环里:先调用大模型推理“我应该做什么”,如果模型判断要调用某个工具,就执行工具拿结果,把结果拼回对话历史,再交给模型继续判断。直到模型认为任务已完成。
我写了一个简化但五脏俱全的实现:
csharp复制public sealed class AgentRunner
{
private readonly ILLMClient _llm;
private readonly IReadOnlyDictionary<string, IAgentTool> _tools;
public AgentRunner(ILLMClient llm, IEnumerable<IAgentTool> tools)
{
_llm = llm;
_tools = tools.ToDictionary(t => t.Name);
}
public async Task<string> RunAsync(
string userGoal,
int maxIterations = 5,
CancellationToken ct = default)
{
var messages = new List<ChatMessage>
{
new("system", BuildSystemPrompt()),
new("user", userGoal)
};
for (var i = 0; i < maxIterations; i++)
{
var response = await _llm.ChatAsync(messages, ct: ct);
messages.Add(new ChatMessage("assistant", response));
var action = ParseAction(response);
if (action is null)
{
return response;
}
if (!_tools.ContainsKey(action.ToolName))
{
var error = $"工具不存在: {action.ToolName}";
messages.Add(new ChatMessage("tool", error));
continue;
}
var toolResult = await _tools[action.ToolName].ExecuteAsync(action.InputJson, ct);
messages.Add(new ChatMessage("tool", toolResult));
}
return "已达到最大迭代次数,任务可能未完成。";
}
}
这段代码解释了 Agent 运行时的最小机制。BuildSystemPrompt 的核心是把所有可用工具的名字和描述拼进去,让模型知道“你能用哪些工具”。前面之所以强调 Description 要写清楚,就是因为这是模型判断要不要调用工具的唯一依据。
csharp复制private string BuildSystemPrompt()
{
var sb = new StringBuilder();
sb.AppendLine("你是一个能调用工具的智能助手。");
sb.AppendLine("如果工具返回的结果足以回答用户问题,请直接给出最终答案。");
sb.AppendLine("当需要调用工具时,请严格输出以下格式:");
sb.AppendLine("【工具调用】工具名,参数JSON");
sb.AppendLine("可用工具列表:");
foreach (var tool in _tools.Values)
{
sb.AppendLine($"- {tool.Name}: {tool.Description}");
}
sb.AppendLine("其他内容一律不要输出,直接输出工具调用或最终答案。");
return sb.ToString();
}
我这里刻意没有引入复杂的 Function Calling 协议或 JSON Schema 校验,而是使用了一种极简的文本协议:【工具调用】工具名,参数JSON。这么做是有意的——先把循环跑通,协议怎么规范后面再迭代。实际上真正生产级的 Agent 平台,确实会有更严谨的协议(比如 OpenAI Function Calling 的 tool_calls),但只要理解了这一步在做什么,后面换成任何规范都是水到渠成的事情。
4.4 结果解析的“模糊匹配”技巧
在 ParseAction 里,我没有用严格的字符串匹配,而是做了一点“抗干扰”处理:
csharp复制private ActionCall? ParseAction(string text)
{
const string marker = "【工具调用】";
var idx = text.IndexOf(marker, StringComparison.Ordinal);
if (idx < 0)
{
return null;
}
var content = text[(idx + marker.Length)..].Trim();
var commaIndex = content.IndexOf(',');
var toolName = content[..commaIndex].Trim();
var inputJson = content[(commaIndex + 1)..].Trim();
return new ActionCall(toolName, inputJson);
}
模型生成文本时偶尔会在前后加一些莫名其妙的换行或解释文字,这种“截取标记之后的内容”的策略,比让模型输出纯 JSON 再反序列化要鲁棒得多。这是实战中很重要的小经验。
5. 数据模型与消息历史:为什么我选择全量保存而非滑动窗口
5.1 消息历史的设计取舍
Agent 每执行一轮,对话消息就会增加。跑一个 5 轮循环,消息列表可能就有十几条。所有人都知道“上下文窗口有限”,但到底怎么管理历史消息,Day1 我试了两种方案。
方案一:滑动窗口。只保留最近 N 条消息。优点是简单、省 Token。缺点很致命——如果模型在某一轮做了一个工具调用,之后这个工具结果被窗口挤掉了,下一轮模型看到的是一个动作但看不到结果,会彻底“精神错乱”。
方案二:全量保存。所有消息都发给模型。优点是上下文完整,模型能准确知道执行到哪一步,代价就是 Token 成本高。
我选了方案二,但不是无脑全量保存,而是加了一个上限保护:当消息数超过某个阈值时,只压缩“早期系统提示词和早期中间步骤”,中间的工具调用和结果必须保留。因为对 Agent 来说,最重要的上下文是最新的几步动作-结果对。
csharp复制const int MaxMessages = 20;
if (messages.Count > MaxMessages)
{
var head = messages[..2]; // 保留 system 和原始 user 目标
var tail = messages[^10]; // 保留最近 10 条关键上下文
messages = head.Concat(tail).ToList(); // 丢弃中间过期的上下文
}
这个策略在 Day1 里没有实现得太精细,但它确定了一个重要的架构方向:消息管理绝不能简单“截断”,要根据 Agent 的执行语义做取舍。
5.2 Token 消耗的粗估
为了给自己一个清晰的成本概念,我随手做了一个粗估计算。假设平均一轮完整循环产生 3000 个 Token 的输入(包含各种系统提示和工具结果),跑 5 轮就是 15000 个 Token 输入,再算输出 2000 个 Token,按主流模型百万 Token 多少钱的定价,单次 Agent 任务成本大概在几分钱到几毛钱之间。
这个数字很重要。它决定了后面上线时要不要做缓存、要不要做消息压缩。Day1 先把这个成本基线记在文档里,等真实业务跑起来再实测。
6. Skill 注册与自动发现:为“热插拔”做的目录设计
6.1 注册中心不等于一个 Dictionary
很多脚手架代码里,工具注册就是一个 Dictionary<string, IAgentTool>。但真要做一个平台,这个设计是不够的。因为你需要:
- 工具的元数据要和实现分离。一个工具的描述、参数定义,有时候需要从数据库或配置文件里加载,而不是写死在代码里。
- 同一类工具可能要区分租户、区分权限。不同的 Agent 能调用的工具集合可能不同。
- 工具可能需要热更新,不停机就能上新功能。
Day1 我做了个折中:定义一个 ToolRegistry 类,内部用一个 ConcurrentDictionary 存工具,但提供注册、反注册、查询三个方法。先把这个调用的“形状”定下来,后面数据源再切换成数据库也不至于推翻重来。
csharp复制public sealed class ToolRegistry
{
private readonly ConcurrentDictionary<string, IAgentTool> _tools = new();
public void Register(IAgentTool tool)
{
_tools[tool.Name] = tool;
}
public bool Unregister(string name) => _tools.TryRemove(name, out _);
public IReadOnlyList<IAgentTool> GetAll() => _tools.Values.ToList();
public bool TryGet(string name, out IAgentTool? tool)
{
return _tools.TryGetValue(name, out tool);
}
}
6.2 元数据生成:能不能别手写 Description?
每个工具注册时都要手写 Description,这在一开始还好,工具一多就变成巨大的心智负担。而且描述写得不好,模型的调用准确率就下降。Day1 我尝试给每个工具自动生成一段“使用说明”,做法是从方法的 XML 注释里抽取 Summary。
csharp复制public static string? GetSummaryFromXml<T>(string memberName)
{
// 用反射读取 T 类型上对应方法的 XML 注释
// 实际生产环境会封装得更完整
return null;
}
这个思路是受不少开源项目启发的:XML 注释 + 反射读取 → 自动拼装模型的系统提示。好处是一处维护、多处复用,注释写好了,Description 自动就有。这个功能我在 Day1 里只做到了“能跑”,但方向已经验证可行,后面可以单独开一篇讲怎么做得更完善。
7. 实测:让 Agent 完成一个完整任务,看它如何“思考”
一切代码就绪,我启动了本地服务,先用最简单的场景做验证。用户输入是:“帮我看看今天是几号,明天星期几。”
理论上,Agent 应该:调用 datetime 工具 → 拿到当前时间 → 推算明天星期几 → 给出最终答案。但理想很丰满,现实往往有各种意外。
7.1 第一次运行:模型没有按约定输出
第一次跑,模型给的回复里没有出现我约定的 【工具调用】 标记,而是直接回答了:
“今天是 2026 年 4 月 27 日,星期一。明天是星期二。”
这个答案其实是对的(说明模型用内部知识回答了当前日期,对常见日期它往往能蒙对),但它没有调用工具,说明系统提示词的作用力不够,模型认为不需要工具就能回答。
解决方式:我在系统提示里加强了约束,明确写了“除非你能100%确认当前时间,否则必须调用 datetime 工具”。再次运行,终于看到了工具调用。
这其实是 ReAct 交互里非常常见的现象,也体现了 Agent 开发的一个关键难点——“工具使用倾向性”的引导。模型天生倾向于“自己直接答”,你必须在系统提示里把它扳到“先取证、再回答”的路径上。
7.2 第二次运行:顺利走完循环
完整对话如下(简化后的记录):
code复制[system] 你是一个能调用工具的智能助手。...
[user] 帮我看看今天是几号,明天星期几?
[assistant] 【工具调用】datetime,{}
[tool] 2026-04-27 17:32:15
[assistant] 今天是 2026 年 4 月 27 日,星期一。明天是 2026 年 4 月 28 日,星期二。
循环在第 2 轮退出,总共消耗消息 4 条,输出正常。这个结果虽然简单,但对整个平台来讲是里程碑式的——它证明了从“用户目标”到“工具调用”到“最终答案”的闭环已经走通。
7.3 尝试更复杂的任务:多步骤协作
我又试了一个需要多次工具调用的目标:“获取当前时间,如果时间早于 12 点,就说‘上午好’,否则说‘下午好’。”
这个目标需要:调用 datetime → 解析小时 → 判断时段 → 返回回答。这意味着 Agent 至少要经过两轮推理,第一轮是工具调用,第二轮是基于工具结果的判断。
实测中,这个场景跑了两遍才成功。第一遍,模型在拿到工具结果后,依然输出了一段“上午好/下午好”的长文本,但里面夹杂了多余的解释。第二遍,我在系统提示里追加了一句:“工具结果拿到后,直接给出简洁最终答案。”模型才干净利落地做出了判断。
这里给我的教训是:Agent 的输出质量和系统提示词的“引导精度”成正比。很多看似是“模型笨”的问题,其实是你没有告诉它该怎么做。
8. 部署与运行:跨平台发布时我踩的三个坑
8.1 坑一:向量数据库 SDK 在 .NET 6 下的兼容性问题
本来 Day1 计划接一个向量数据库作为记忆组件,结果发现当前几个主流向量数据库的 C# SDK 对 .NET 6 的支持都处在“能用但文档不全”的状态。有的 SDK 要求 .NET 8,有的 API 设计得很别扭。
我的处理方式:暂时搁置“长期记忆”组件,先用内存字典模拟语义记忆,把接口定义清楚,等下一个迭代再用真实库把实现替换掉。这也是 Day1 的重要经验——定义接口比实现接口更重要,接口稳定了,底层替换的代价就很小。
8.2 坑二:HttpClient 实例的创建方式
这是个老生常谈但必须再强调的坑。在最开始的实现里,如果每创建一个 OpenAICompatibleClient 就 new HttpClient(),在高并发下会导致 socket 端口耗尽。
正确的做法是使用 IHttpClientFactory 来管理 HttpClient 的生命周期。这是 .NET 里的最佳实践,和 Agent 平台没有直接关系,但一旦平台用户量上来,这个坑必然爆。
csharp复制builder.Services.AddHttpClient<ILLMClient, OpenAICompatibleClient>((sp, httpClient) =>
{
var config = sp.GetRequiredService<IConfiguration>();
httpClient.BaseAddress = new Uri(config["LLM:Endpoint"]);
});
8.3 坑三:配置管理的自动化
本地开发时我把模型 API Key 写在了 appsettings.json 里。提交代码前我改了忽略列表,然后用环境变量注入 Key。这样本地联调用的是个人 Key,部署到服务器用环境变量覆盖,整个过程不需要改代码。
bash复制export LLM__ApiKey=your-key-here
export LLM__Endpoint=https://your-llm-gateway.example.com
注意这是 ASP.NET Core 配置系统的默认行为,环境变量名里的 __ 对应配置层级(冒号 : 的替代),在 Linux 环境下很常用。
9. 今天的总结和 Day2 计划
9.1 Day1 到底完成了什么
一句话:跑通了一个最小可用的 AI Agent 闭环。
具体来说:
- 定了技术栈:.NET 6 + C# 10,跨平台方案已落地。
- 定了架构骨架:LLM 网关、Agent 运行时、工具注册中心三层分离。
- 实现了 Agent 运行时的核心 ReAct 循环,验证了模型调用工具和基于工具结果推理的能力。
- 实现了日期时间工具的注册和调用。
- 验证了跨平台发布的坑和应对方案。
9.2 Day1 期间积累的几条核心经验
第一,Agent 平台的架构抽象不是越多越好,而是越稳越好。Day1 我把注意力集中在“一个工具调用闭环”上,没有引入复杂的多 Agent 协作、规划器、记忆检索等概念。先把最小闭环跑通,后面每加一个模块都是增量演进,风险可控。
第二,模型的行为受系统提示词影响极大。同一套代码,系统提示词写得含糊,模型就喜欢自由发挥;写得精确,模型就规规矩矩走工具调用链路。这会是一个需要持续调优的领域。
第三,工具注册中心的设计直接决定平台的扩展边界。把工具和运行时解耦,意味着新增能力不需要动核心代码,只加新工具就行。这个决定给后面节省了大量重构时间。
9.3 Day2 计划
- 接入真实的流式输出(SSE),提升请求响应体验。
- 引入 Function Calling(工具调用的正式协议),替代现在的文本约定。
- 实现一个基于语义检索的记忆模块,让 Agent 能记住长期信息。
- 把工具注册中心的数据源从内存切换到数据库,支持热更新配置文件。
Day1 到这里就收工了。如果你也在做类似的 Agent 平台,或者对某个环节有疑问,后续几篇我会把这些内容逐步展开写细。
