郦惠 发表于 4 天前

用 C# 写一个完整的 ReAct 智能体:从命令行输入到任务完成的全链路拆解

去年断断续续用 C# 写了一个命令行智能体框架,最近总算跑通了整个流程。Python 的 LangChain、AutoGen 已经烂大街了,但 .NET 这边一直缺个轻量级的、能直接看懂代码的 Agent 实现。这篇文章不讲概念,直接沿着一条请求从头走到尾,把每一步对应的代码摊开来讲。
项目地址:liuzhixin405/reactagent-netcore
全局流程一图流
先上一张图,后面所有内容围绕这条线展开:

 


[*]第一站:Program.Main — 启动与路由
当你敲下 aicli agent run general "创建一个计算器项目" 回车后,程序进入 Program.Main。这里做的事情很直白:
// src/AiCli.Cli/Program.cs
public static async Task<int> Main(string[] args)
{
    Console.InputEncoding = System.Text.Encoding.UTF8;
    Console.OutputEncoding = System.Text.Encoding.UTF8;

    await using var config = new Config();
    await config.InitializeAsync();

    var rootCommand = new RootCommand("aicli");

    var agentCommand = new AgentCommand(config);
    rootCommand.AddCommand(agentCommand.Command);
    // ... 其他子命令

    return await rootCommand.InvokeAsync(args);
}基于 System.CommandLine,agent run general "..." 会被路由到 AgentCommand.HandleRunAsync。如果不传任何参数,程序会弹出一个交互式菜单让你选模式(聊天 / Agent / 单次 Prompt),还会打开一个 Windows 文件夹选择对话框让你指定工作目录——这个细节保证了工具操作文件时的路径安全。


[*]第二站:RegisterToolsAndAgents — 组装"工具箱"和"团队"
路由到 HandleRunAsync 之后,第一件事不是创建 Agent,而是先把工具和 Agent 注册好。这是整个框架的关键设计——工具和 Agent 解耦,通过注册表组装:
// src/AiCli.Cli/Commands/AgentCommand.cs
private void RegisterToolsAndAgents(IContentGenerator contentGenerator)
{
    var targetDir = Directory.GetCurrentDirectory();

    // 第一步:注册所有工具
    _toolRegistry.Clear();
    _toolRegistry.RegisterDiscoveredTool(new ReadFileTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new WriteFileTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new ShellTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new GrepTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new GlobTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new LsTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new EditTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(new WebFetchTool());
    _toolRegistry.RegisterDiscoveredTool(new WebSearchTool());
    _toolRegistry.RegisterDiscoveredTool(new MemoryTool(_config));

    // 第二步:选模型——Agent 用支持 function calling 的快速模型
    IContentGenerator agentGen = contentGenerator;
    if (contentGenerator is MultiModelOrchestrator mmo)
    {
      agentGen = mmo.GetGenerator(ModelRole.Fast); // qwen2.5-coder:7b
    }

    // 第三步:注册不同类型的 Agent
    _agentRegistry.RegisterAgent(new GeneralPurposeAgent("general", _toolRegistry, agentGen));
    _agentRegistry.RegisterAgent(new ExploreAgent("explore", _toolRegistry, agentGen));
    _agentRegistry.RegisterAgent(new PlanAgent("plan", _toolRegistry, agentGen));
    _agentRegistry.RegisterAgent(new CodeAgent("code", _toolRegistry, agentGen));
}这里有个实战中踩出来的决策:MultiModelOrchestrator 内部管理三个模型(embedding 用 bge-m3、思考用 gpt-oss:20b、快速执行用 qwen2.5-coder:7b),但 Agent 场景只用快速模型。原因是思考模型开了 think:true 之后带工具调用时容易"只推理不执行",卡在那里不动。这种问题只有实际跑过才会发现。
ContentGeneratorFactory 的选择逻辑也值得一看:
// src/AiCli.Core/Chat/GoogleContentGenerator.cs
public static IContentGenerator Create(Config config)
{
    var forceLocal = Environment.GetEnvironmentVariable("AICLI_USE_LOCAL_GENERATOR");
    if (!string.IsNullOrWhiteSpace(forceLocal) && forceLocal.Equals("true", StringComparison.OrdinalIgnoreCase))
      return new ContentGenerator(config);

    if (CanUseOllamaApi(config))
      return new MultiModelOrchestrator(config);// 本地多模型

    if (CanUseGoogleApi(config))
      return new GoogleContentGenerator(config);   // Google Cloud

    return new ContentGenerator(config);            // 兜底
}环境变量 > Ollama本地 > Google API > 兜底,优先级很明确。


[*]第三站:ExecuteAsync — ReAct 循环的核心
Agent 拿到用户消息后,进入 ExecuteAsync。这是整个框架最核心的 50 行代码:
// src/AiCli.Core/Agents/Agent.cs
public virtual async Task ExecuteAsync(
    ContentMessage message, CancellationToken cancellationToken = default)
{
    // 1. 预处理:自动扫描工作目录,把目录快照注入用户消息
    var initialMessage = await TryEnrichInitialMessageAsync(message, linkedCts.Token);
    _messageHistory.Add(initialMessage);

    // 2. 第一次调 LLM
    var response = await GenerateResponseWithToolsAsync(linkedCts.Token);
    _messageHistory.Add(response);

    // 3. ReAct 循环:LLM 返回工具调用 → 执行 → 结果塞回历史 → 再调 LLM
    var turnCount = 0;
    while (ContainsToolCalls(response) && turnCount < 100)
    {
      turnCount++;
      foreach (var toolCall in response.Parts.OfType<FunctionCallPart>())
      {
            EmitEvent(AgentEventType.ToolCalled, callLabel);   // UI 显示 "◌ write_file Program.cs"

            var tool = ToolRegistry.GetTool(toolCall.FunctionName);
            var result = await ExecuteToolAsync(tool, toolCall.Arguments, linkedCts.Token);

            // 关键:把工具结果作为 Function 角色消息追加到历史
            _messageHistory.Add(CreateToolResultMessage(toolCall.FunctionName, result));
            EmitEvent(AgentEventType.ToolCompleted, callLabel); // UI 显示 "✓ write_file Program.cs"
      }

      // 带上完整历史再请求 LLM,让它决定下一步
      response = await GenerateResponseWithToolsAsync(linkedCts.Token);
      _messageHistory.Add(response);
    }

    return new AgentResult { State = AgentExecutionState.Completed, Messages = messages, ... };
}while 循环里发生的事情,用人话说就是:
LLM 看到用户任务 + 目录快照 → 决定调 shell 执行 dotnet new → 框架执行 shell 命令 → 把输出喂回给 LLM → LLM 再决定调 write_file 写代码 → 框架写文件 → 把结果喂回 → LLM 再调 shell 执行 dotnet build → 编译通过 → LLM 说"搞定了" → 循环结束。
每一轮的消息历史大概长这样:

 


[*]第四站:GenerateResponseWithToolsAsync — 和 LLM 的通信细节
这个方法构建请求、处理流式响应,是连接框架和 LLM 的桥梁:
// src/AiCli.Core/Agents/Agent.cs
protected virtual async Task<ContentMessage> GenerateResponseWithToolsAsync(CancellationToken ct)
{
    var request = new GenerateContentRequest
    {
      Model = Chat.GetModelId(),
      Contents = new List<ContentMessage>(_messageHistory),// 完整历史
      SystemInstruction = GetSystemInstruction(),             // Agent 特定的系统指令
      Tools = ToolRegistry.GetTools(modelId),               // 所有工具的 JSON Schema
      ToolConfig = new ToolConfig { ... }
    };

    var textChunks = new List<string>();
    var toolCalls = new List<FunctionCallPart>();

    // 流式接收:思考 token 实时推送到 UI,文本和工具调用累积
    await foreach (var chunk in Chat.GenerateContentStreamAsync(request, ct))
    {
      foreach (var part in candidate.Content)
      {
            switch (part)
            {
                case ThinkingContentPart tp:
                  EmitEvent(AgentEventType.Thinking, tp.Text);// 实时显示思考过程
                  break;
                case TextContentPart tx:
                  textChunks.Add(tx.Text);
                  break;
                case FunctionCallPart fc:
                  toolCalls.Add(fc);
                  break;
            }
      }
    }

    // 兼容处理:有些模型把工具调用以文本 JSON 输出,而非原生 tool_calls 字段
    if (toolCalls.Count == 0 && textChunks.Count > 0)
    {
      var textParsed = ParseTextFormatToolCalls(string.Concat(textChunks));
      if (textParsed.Count > 0) { toolCalls.AddRange(textParsed); textChunks.Clear(); }
    }
    // ...
}这里有两个值得注意的设计:
1.        ThinkingContentPart 的实时推送:支持 reasoning 模型(如 gpt-oss:20b、qwen3)输出的内部推理过程,通过事件机制实时显示在终端。
2.        文本格式工具调用的兜底解析:qwen2.5-coder 有时候不走 Ollama 原生的 tool_calls 字段,而是直接输出 {"name":"read_file","arguments":{"file_path":"..."}}。框架会尝试解析这种文本并转换为 FunctionCallPart,保证链路不断。


[*]第五站:工具执行 — 从 Schema 到真正跑起来
当 LLM 决定调用 read_file 时,参数从 JSON 变成实际文件操作的链路是这样的:
FunctionCallPart { FunctionName="read_file", Arguments={"file_path":"Program.cs"} }
→ ToolRegistry.GetTool("read_file")      // 从注册表找到 ReadFileTool
→ tool.Build(arguments)                     // 参数验证 + 创建 Invocation
    → IToolBuilder<TParams,TResult>.Build()   // 自动 snake_case → PascalCase 转换
    → ReadFileTool.Build(params)            // 路径解析:相对路径 → 绝对路径
→ LocalExecutor.ExecuteAsync(invocation)    // 带超时(5分钟)执行
→ ToolExecutionResult { Output="文件内容...", IsError=false }IToolBuilder 接口的 Build 方法有一段自动转换逻辑,因为 LLM 输出的参数键名是 snake_case(file_path),但 C# 的参数类是 PascalCase(FilePath):
// src/AiCli.Core/Tools/IToolBuilder.cs — 接口默认实现
IToolInvocation IToolBuilder.Build(object parameters)
{
    if (parameters is Dictionary<string, object?> dict)
    {
      // "file_path" → "FilePath","start_line" → "StartLine"
      var normalized = NormalizeKeys(dict);
      var json = JsonSerializer.Serialize(normalized);
      var obj = JsonSerializer.Deserialize<TParams>(json);
      if (obj is not null) return Build(obj);
    }
    throw new InvalidCastException(...);
}工具执行结果被包装成 FunctionResponsePart 塞回消息历史:
protected ContentMessage CreateToolResultMessage(string functionName, ToolExecutionResult result)
{
    return new ContentMessage
    {
      Role = LlmRole.Function,
      Parts = new List<ContentPart>
      {
            new FunctionResponsePart
            {
                FunctionName = functionName,
                Response = new Dictionary<string, object?>
                {
                  ["result"] = result.Output,
                  ["is_error"] = result.IsError
                }
            }
      }
    };
}这条消息回到 _messageHistory,下一轮 GenerateResponseWithToolsAsync 就能把它带给 LLM。


[*]第六站:UI 渲染 — 实时反馈
AgentCommand 通过订阅 agent.OnEvent 驱动终端显示。LiveTaskListRenderer 用 ANSI 转义码实现逐行更新:
// src/AiCli.Cli/Commands/AgentCommand.cs
agent.OnEvent += (_, e) =>
{
    switch (e.Type)
    {
      case AgentEventType.Thinking:
            renderer.Add("◆ 思考中...预览文本...");   // 显示 ⠋ 旋转动画
            break;
      case AgentEventType.ToolCalled:
            renderer.Add("write_fileProgram.cs");       // 显示 ⠋ 旋转动画
            break;
      case AgentEventType.ToolCompleted:
            renderer.CompleteLast();                     // ⠋ → ✓
            break;
    }
};终端效果类似 Claude Code 的任务列表:
✓ ◆ 思考完成 (2.3s)
✓ shelldotnet new console -n Calculator -o Calculator
✓ write_fileProgram.cs
✓ shelldotnet build
──────────────────────────────────────────────────────────
共执行 4 个工具调用,耗时 12.8s任务完成后,RenderAgentResult 提取最后一条模型文本消息作为总结输出。如果检测到有文件写入操作,还会自动跑 dotnet build 做编译验证。
整条链路的数据流
最后用一张表总结一次完整请求中,核心数据结构是如何在各层之间传递的:

 这就是一条用户输入从进入 Main 到看到终端输出 ✓ 任务已完成 的完整路径。整个框架没有黑魔法,核心就是 while 循环 + 消息历史 + 工具注册表,剩下的都是工程细节。


[*]第七站:Core 核心代码深度拆解
上一部分走完了从 CLI 输入到任务完成的主线流程。这一节把 AiCli.Core 里的核心抽象拆开来讲——这些是让整个框架能灵活扩展的骨架。


[*]7.1 类型系统:ContentPart 判别联合
整个框架的数据基础是 ContentPart。LLM 返回的东西五花八门——普通文本、思考过程、工具调用请求、代码执行结果——全部统一成一棵继承树:
classDiagram
    ContentPart <|-- TextContentPart
    ContentPart <|-- ThinkingContentPart
    ContentPart <|-- FunctionCallPart
    ContentPart <|-- FunctionResponsePart
    ContentPart <|-- InlineDataPart
    ContentPart <|-- ExecutableCodePart
    ContentPart <|-- CodeExecutionResultPart
    ContentPart <|-- ThoughtPart
    ContentPart <|-- FileDataPart

    class ContentPart {
      <>
    }
    class TextContentPart {
      +string Text
    }
    class ThinkingContentPart {
      +string Text
    }
    class FunctionCallPart {
      +string FunctionName
      +Dictionary Arguments
      +string Id
    }
    class FunctionResponsePart {
      +string FunctionName
      +Dictionary Response
      +string Id
    }

[*]7.3 LLM 通信层:OllamaContentGenerator
这一层负责把框架内部的 ContentMessage 列表翻译成 Ollama API 的 JSON 格式,再把响应翻译回来。流式场景的处理最复杂:
// 在 Agent.GenerateResponseWithToolsAsync 中,用模式匹配分流不同类型
foreach (var part in candidate.Content)
{
    switch (part)
    {
      case ThinkingContentPart tp:// 思考 token → 实时推给 UI
            EmitEvent(AgentEventType.Thinking, tp.Text);
            break;
      case TextContentPart tx:      // 普通文本 → 累积
            textChunks.Add(tx.Text);
            break;
      case FunctionCallPart fc:   // 工具调用 → 收集起来待执行
            toolCalls.Add(fc);
            break;
    }
}思考内容有两种输出格式需要兼容:
•        Ollama 原生字段:{"message":{"thinking":"...","content":"..."}}(gpt-oss:20b 开启 think:true)
•        XML 标签内嵌:{"message":{"content":"推理过程最终回答"}}(deepseek-r1 风格)
ExtractThinkTagsIntoPartsInPlace 方法用字符串扫描把 ... 拆成 ThinkingContentPart 和 TextContentPart 交替序列,处理了未闭合标签(流式传输中常见)的边界情况。


[*]7.4 多模型编排:MultiModelOrchestrator
本地跑 Ollama 时,不同任务适合不同模型。MultiModelOrchestrator 内部持有三个 OllamaContentGenerator 实例:
var display = part.Match(
    textHandler: t => t.Text,
    functionCallHandler: fc => $"调用 {fc.FunctionName}",
    thoughtHandler: th => $"[思考] {th.Thought}"
    // 缺少其他 handler → 运行时 InvalidOperationException
);它对外实现 IContentGenerator 接口,默认委托给思考模型,保持向后兼容。但 Agent 场景会显式取快速模型:
IToolBuilder          → 注册表用:提供名称、Schema、构建能力
└─ DeclarativeTool→ 基类:参数验证 + Schema 生成
       └─ ShellTool   → 具体工具:实际执行逻辑

IToolInvocation       → 执行器用:一次已验证的工具调用
└─ BaseToolInvocation → 基类:确认、描述、位置信息
       └─ ShellToolInvocation → 具体执行:启动进程、收集输出 


[*]7.5 Agent 类型体系
四种内置 Agent 继承同一个 Agent 基类,区别只在 系统指令(System Instruction) 和 能力标签(Capabilities):

 
它们的 ExecuteToolAsync 实现完全相同——都是 LocalExecutor + ApprovalMode.Auto。真正的差异化来自 GetSystemInstruction() 返回的 prompt,这决定了 LLM 在 ReAct 循环中的行为模式。
PlanAgent 额外提供了 CreatePlanAsync / ValidatePlanAsync 等方法,支持独立的计划-验证工作流,不一定要走完整的 ReAct 循环。
 
最后:
这本质上是一个学习性质的实现——把 ReAct 模式的每个环节用 C# 从零写了一遍,踩了不少坑(比如 qwen 模型不走原生 tool_calls、思考模型带工具时卡住、snake_case 参数转换等),这些经验可能比代码本身更有价值。
当前项目可能需要更加适合的模型,根据模型的特点来调整流程才能往一点点的往前看,当前Caude、Copilot等成熟的cli和客户端加上自己的大模型已经让大家非常痛快的掏钱去投入实际的开发中,有没有必要继续研究智能体的开发是一个很头痛的问题。
庆幸的是现在有好多开源的智能体的项目,也是有做各种参考学习。AI真的来了,作为程序员喜忧参半,未来影响怎么样拭目以待吧!

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: 用 C# 写一个完整的 ReAct 智能体:从命令行输入到任务完成的全链路拆解