烛芯语 發表於 2026-3-17 23:29:00

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

去年断断续续用 C# 写了一个命令行智能体框架,最近总算跑通了整个流程。Python 的 LangChain、AutoGen 已经烂大街了,但 .NET 这边一直缺个轻量级的、能直接看懂代码的 Agent 实现。这篇文章不讲概念,直接沿着一条请求从头走到尾,把每一步对应的代码摊开来讲。<br>
<p>项目地址:liuzhixin405/reactagent-netcore<br>全局流程一图流<br>先上一张图,后面所有内容围绕这条线展开:</p>
<p><img src="https://img2024.cnblogs.com/blog/1099890/202603/1099890-20260317230339727-129639276.png"></p>
<p>&nbsp;</p>
<ul>
<li>第一站:Program.Main — 启动与路由</li>
</ul>
<p>当你敲下 aicli agent run general "创建一个计算器项目" 回车后,程序进入 Program.Main。这里做的事情很直白:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Cli/Program.cs</span>
<span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">async</span> Task&lt;<span style="color: rgba(0, 0, 255, 1)">int</span>&gt; Main(<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">[] args)
{
    Console.InputEncoding </span>=<span style="color: rgba(0, 0, 0, 1)"> System.Text.Encoding.UTF8;
    Console.OutputEncoding </span>=<span style="color: rgba(0, 0, 0, 1)"> System.Text.Encoding.UTF8;

    </span><span style="color: rgba(0, 0, 255, 1)">await</span> <span style="color: rgba(0, 0, 255, 1)">using</span> <span style="color: rgba(0, 0, 255, 1)">var</span> config = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> Config();
    </span><span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> config.InitializeAsync();

    </span><span style="color: rgba(0, 0, 255, 1)">var</span> rootCommand = <span style="color: rgba(0, 0, 255, 1)">new</span> RootCommand(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">aicli</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);

    </span><span style="color: rgba(0, 0, 255, 1)">var</span> agentCommand = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> AgentCommand(config);
    rootCommand.AddCommand(agentCommand.Command);
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> ... 其他子命令</span>

    <span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> rootCommand.InvokeAsync(args);
}</span></pre>
</div>
<p>基于 System.CommandLine,agent run general "..." 会被路由到 AgentCommand.HandleRunAsync。如果不传任何参数,程序会弹出一个交互式菜单让你选模式(聊天 / Agent / 单次 Prompt),还会打开一个 Windows 文件夹选择对话框让你指定工作目录——这个细节保证了工具操作文件时的路径安全。</p>
<ul>
<li>第二站:RegisterToolsAndAgents — 组装"工具箱"和"团队"</li>
</ul>
<p>路由到 HandleRunAsync 之后,第一件事不是创建 Agent,而是先把工具和 Agent 注册好。这是整个框架的关键设计——工具和 Agent 解耦,通过注册表组装:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Cli/Commands/AgentCommand.cs</span>
<span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> RegisterToolsAndAgents(IContentGenerator contentGenerator)
{
    </span><span style="color: rgba(0, 0, 255, 1)">var</span> targetDir =<span style="color: rgba(0, 0, 0, 1)"> Directory.GetCurrentDirectory();

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 第一步:注册所有工具</span>
<span style="color: rgba(0, 0, 0, 1)">    _toolRegistry.Clear();
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ReadFileTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> WriteFileTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ShellTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> GrepTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> GlobTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> LsTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> EditTool(targetDir));
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> WebFetchTool());
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> WebSearchTool());
    _toolRegistry.RegisterDiscoveredTool(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> MemoryTool(_config));

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 第二步:选模型——Agent 用支持 function calling 的快速模型</span>
    IContentGenerator agentGen =<span style="color: rgba(0, 0, 0, 1)"> contentGenerator;
    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (contentGenerator <span style="color: rgba(0, 0, 255, 1)">is</span><span style="color: rgba(0, 0, 0, 1)"> MultiModelOrchestrator mmo)
    {
      agentGen </span>= mmo.GetGenerator(ModelRole.Fast); <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> qwen2.5-coder:7b</span>
<span style="color: rgba(0, 0, 0, 1)">    }

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

    </span><span style="color: rgba(0, 0, 255, 1)">if</span><span style="color: rgba(0, 0, 0, 1)"> (CanUseOllamaApi(config))
      </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span> MultiModelOrchestrator(config);<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 本地多模型</span>

    <span style="color: rgba(0, 0, 255, 1)">if</span><span style="color: rgba(0, 0, 0, 1)"> (CanUseGoogleApi(config))
      </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span> GoogleContentGenerator(config);   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> Google Cloud</span>

    <span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span> ContentGenerator(config);            <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 兜底</span>
}</pre>
</div>
<p>环境变量 &gt; Ollama本地 &gt; Google API &gt; 兜底,优先级很明确。</p>
<ul>
<li>第三站:ExecuteAsync — ReAct 循环的核心</li>
</ul>
<p>Agent 拿到用户消息后,进入 ExecuteAsync。这是整个框架最核心的 50 行代码:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Core/Agents/Agent.cs</span>
<span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">virtual</span> <span style="color: rgba(0, 0, 255, 1)">async</span> Task&lt;AgentResult&gt;<span style="color: rgba(0, 0, 0, 1)"> ExecuteAsync(
    ContentMessage message, CancellationToken cancellationToken </span>= <span style="color: rgba(0, 0, 255, 1)">default</span><span style="color: rgba(0, 0, 0, 1)">)
{
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1. 预处理:自动扫描工作目录,把目录快照注入用户消息</span>
    <span style="color: rgba(0, 0, 255, 1)">var</span> initialMessage = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> TryEnrichInitialMessageAsync(message, linkedCts.Token);
    _messageHistory.Add(initialMessage);

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 2. 第一次调 LLM</span>
    <span style="color: rgba(0, 0, 255, 1)">var</span> response = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> GenerateResponseWithToolsAsync(linkedCts.Token);
    _messageHistory.Add(response);

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

            <span style="color: rgba(0, 0, 255, 1)">var</span> tool =<span style="color: rgba(0, 0, 0, 1)"> ToolRegistry.GetTool(toolCall.FunctionName);
            </span><span style="color: rgba(0, 0, 255, 1)">var</span> result = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> ExecuteToolAsync(tool, toolCall.Arguments, linkedCts.Token);

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

      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 带上完整历史再请求 LLM,让它决定下一步</span>
      response = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> GenerateResponseWithToolsAsync(linkedCts.Token);
      _messageHistory.Add(response);
    }

    </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span> AgentResult { State = AgentExecutionState.Completed, Messages =<span style="color: rgba(0, 0, 0, 1)"> messages, ... };
}</span></pre>
</div>
<p>while 循环里发生的事情,用人话说就是:<br>LLM 看到用户任务 + 目录快照 → 决定调 shell 执行 dotnet new → 框架执行 shell 命令 → 把输出喂回给 LLM → LLM 再决定调 write_file 写代码 → 框架写文件 → 把结果喂回 → LLM 再调 shell 执行 dotnet build → 编译通过 → LLM 说"搞定了" → 循环结束。<br>每一轮的消息历史大概长这样:</p>
<p><img src="https://img2024.cnblogs.com/blog/1099890/202603/1099890-20260317230602478-258403657.png"></p>
<p>&nbsp;</p>
<ul>
<li>第四站:GenerateResponseWithToolsAsync — 和 LLM 的通信细节</li>
</ul>
<p>这个方法构建请求、处理流式响应,是连接框架和 LLM 的桥梁:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Core/Agents/Agent.cs</span>
<span style="color: rgba(0, 0, 255, 1)">protected</span> <span style="color: rgba(0, 0, 255, 1)">virtual</span> <span style="color: rgba(0, 0, 255, 1)">async</span> Task&lt;ContentMessage&gt;<span style="color: rgba(0, 0, 0, 1)"> GenerateResponseWithToolsAsync(CancellationToken ct)
{
    </span><span style="color: rgba(0, 0, 255, 1)">var</span> request = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> GenerateContentRequest
    {
      Model </span>=<span style="color: rgba(0, 0, 0, 1)"> Chat.GetModelId(),
      Contents </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> List&lt;ContentMessage&gt;(_messageHistory),<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 完整历史</span>
      SystemInstruction = GetSystemInstruction(),             <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> Agent 特定的系统指令</span>
      Tools = ToolRegistry.GetTools(modelId),               <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 所有工具的 JSON Schema</span>
      ToolConfig = <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ToolConfig { ... }
    };

    </span><span style="color: rgba(0, 0, 255, 1)">var</span> textChunks = <span style="color: rgba(0, 0, 255, 1)">new</span> List&lt;<span style="color: rgba(0, 0, 255, 1)">string</span>&gt;<span style="color: rgba(0, 0, 0, 1)">();
    </span><span style="color: rgba(0, 0, 255, 1)">var</span> toolCalls = <span style="color: rgba(0, 0, 255, 1)">new</span> List&lt;FunctionCallPart&gt;<span style="color: rgba(0, 0, 0, 1)">();

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

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 兼容处理:有些模型把工具调用以文本 JSON 输出,而非原生 tool_calls 字段</span>
    <span style="color: rgba(0, 0, 255, 1)">if</span> (toolCalls.Count == <span style="color: rgba(128, 0, 128, 1)">0</span> &amp;&amp; textChunks.Count &gt; <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> textParsed = ParseTextFormatToolCalls(<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">.Concat(textChunks));
      </span><span style="color: rgba(0, 0, 255, 1)">if</span> (textParsed.Count &gt; <span style="color: rgba(128, 0, 128, 1)">0</span><span style="color: rgba(0, 0, 0, 1)">) { toolCalls.AddRange(textParsed); textChunks.Clear(); }
    }
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> ...</span>
}</pre>
</div>
<p>这里有两个值得注意的设计:<br>1.        ThinkingContentPart 的实时推送:支持 reasoning 模型(如 gpt-oss:20b、qwen3)输出的内部推理过程,通过事件机制实时显示在终端。<br>2.        文本格式工具调用的兜底解析:qwen2.5-coder 有时候不走 Ollama 原生的 tool_calls 字段,而是直接输出 {"name":"read_file","arguments":{"file_path":"..."}}。框架会尝试解析这种文本并转换为 FunctionCallPart,保证链路不断。</p>
<ul>
<li>第五站:工具执行 — 从 Schema 到真正跑起来</li>
</ul>
<p>当 LLM 决定调用 read_file 时,参数从 JSON 变成实际文件操作的链路是这样的:</p>
<div class="cnblogs_code">
<pre>FunctionCallPart { FunctionName=<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">read_file</span><span style="color: rgba(128, 0, 0, 1)">"</span>, Arguments={<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">file_path</span><span style="color: rgba(128, 0, 0, 1)">"</span>:<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Program.cs</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">} }
→ ToolRegistry.GetTool(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">read_file</span><span style="color: rgba(128, 0, 0, 1)">"</span>)      <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 从注册表找到 ReadFileTool</span>
→ tool.Build(arguments)                     <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 参数验证 + 创建 Invocation</span>
    → IToolBuilder&lt;TParams,TResult&gt;.Build()   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 自动 snake_case → PascalCase 转换</span>
    → ReadFileTool.Build(<span style="color: rgba(0, 0, 255, 1)">params</span>)            <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 路径解析:相对路径 → 绝对路径</span>
→ LocalExecutor.ExecuteAsync(invocation)    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 带超时(5分钟)执行</span>
→ ToolExecutionResult { Output=<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">文件内容...</span><span style="color: rgba(128, 0, 0, 1)">"</span>, IsError=<span style="color: rgba(0, 0, 255, 1)">false</span> }</pre>
</div>
<p>IToolBuilder 接口的 Build 方法有一段自动转换逻辑,因为 LLM 输出的参数键名是 snake_case(file_path),但 C# 的参数类是 PascalCase(FilePath):</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Core/Tools/IToolBuilder.cs — 接口默认实现</span>
IToolInvocation IToolBuilder.Build(<span style="color: rgba(0, 0, 255, 1)">object</span><span style="color: rgba(0, 0, 0, 1)"> parameters)
{
    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (parameters <span style="color: rgba(0, 0, 255, 1)">is</span> Dictionary&lt;<span style="color: rgba(0, 0, 255, 1)">string</span>, <span style="color: rgba(0, 0, 255, 1)">object</span>?&gt;<span style="color: rgba(0, 0, 0, 1)"> dict)
    {
      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> "file_path" → "FilePath","start_line" → "StartLine"</span>
      <span style="color: rgba(0, 0, 255, 1)">var</span> normalized =<span style="color: rgba(0, 0, 0, 1)"> NormalizeKeys(dict);
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> json =<span style="color: rgba(0, 0, 0, 1)"> JsonSerializer.Serialize(normalized);
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> obj = JsonSerializer.Deserialize&lt;TParams&gt;<span style="color: rgba(0, 0, 0, 1)">(json);
      </span><span style="color: rgba(0, 0, 255, 1)">if</span> (obj <span style="color: rgba(0, 0, 255, 1)">is</span> not <span style="color: rgba(0, 0, 255, 1)">null</span>) <span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> Build(obj);
    }
    </span><span style="color: rgba(0, 0, 255, 1)">throw</span> <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> InvalidCastException(...);
}</span></pre>
</div>
<p>工具执行结果被包装成 FunctionResponsePart 塞回消息历史:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">protected</span> ContentMessage CreateToolResultMessage(<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> functionName, ToolExecutionResult result)
{
    </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ContentMessage
    {
      Role </span>=<span style="color: rgba(0, 0, 0, 1)"> LlmRole.Function,
      Parts </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> List&lt;ContentPart&gt;<span style="color: rgba(0, 0, 0, 1)">
      {
            </span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> FunctionResponsePart
            {
                FunctionName </span>=<span style="color: rgba(0, 0, 0, 1)"> functionName,
                Response </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> Dictionary&lt;<span style="color: rgba(0, 0, 255, 1)">string</span>, <span style="color: rgba(0, 0, 255, 1)">object</span>?&gt;<span style="color: rgba(0, 0, 0, 1)">
                {
                  [</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">result</span><span style="color: rgba(128, 0, 0, 1)">"</span>] =<span style="color: rgba(0, 0, 0, 1)"> result.Output,
                  [</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">is_error</span><span style="color: rgba(128, 0, 0, 1)">"</span>] =<span style="color: rgba(0, 0, 0, 1)"> result.IsError
                }
            }
      }
    };
}</span></pre>
</div>
<p>这条消息回到 _messageHistory,下一轮 GenerateResponseWithToolsAsync 就能把它带给 LLM。</p>
<ul>
<li>第六站:UI 渲染 — 实时反馈</li>
</ul>
<p>AgentCommand 通过订阅 agent.OnEvent 驱动终端显示。LiveTaskListRenderer 用 ANSI 转义码实现逐行更新:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Cli/Commands/AgentCommand.cs</span>
agent.OnEvent += (_, e) =&gt;<span style="color: rgba(0, 0, 0, 1)">
{
    </span><span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)"> (e.Type)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">case</span><span style="color: rgba(0, 0, 0, 1)"> AgentEventType.Thinking:
            renderer.Add(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">◆ 思考中...预览文本...</span><span style="color: rgba(128, 0, 0, 1)">"</span>);   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 显示 ⠋ 旋转动画</span>
            <span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
      </span><span style="color: rgba(0, 0, 255, 1)">case</span><span style="color: rgba(0, 0, 0, 1)"> AgentEventType.ToolCalled:
            renderer.Add(</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">write_fileProgram.cs</span><span style="color: rgba(128, 0, 0, 1)">"</span>);       <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 显示 ⠋ 旋转动画</span>
            <span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
      </span><span style="color: rgba(0, 0, 255, 1)">case</span><span style="color: rgba(0, 0, 0, 1)"> AgentEventType.ToolCompleted:
            renderer.CompleteLast();                     </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> ⠋ → ✓</span>
            <span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
    }
};</span></pre>
</div>
<p>终端效果类似 Claude Code 的任务列表:</p>
<div class="cnblogs_code">
<pre>✓ ◆ 思考完成 (<span style="color: rgba(128, 0, 128, 1)">2</span><span style="color: rgba(0, 0, 0, 1)">.3s)
✓ shelldotnet </span><span style="color: rgba(0, 0, 255, 1)">new</span> console -n Calculator -<span style="color: rgba(0, 0, 0, 1)">o Calculator
✓ write_fileProgram.cs
✓ shelldotnet build
──────────────────────────────────────────────────────────
共执行 </span><span style="color: rgba(128, 0, 128, 1)">4</span> 个工具调用,耗时 <span style="color: rgba(128, 0, 128, 1)">12</span>.8s</pre>
</div>
<p>任务完成后,RenderAgentResult 提取最后一条模型文本消息作为总结输出。如果检测到有文件写入操作,还会自动跑 dotnet build 做编译验证。<br>整条链路的数据流<br>最后用一张表总结一次完整请求中,核心数据结构是如何在各层之间传递的:</p>
<p><img src="https://img2024.cnblogs.com/blog/1099890/202603/1099890-20260317230926146-774320235.png"></p>
<p>&nbsp;这就是一条用户输入从进入 Main 到看到终端输出 ✓ 任务已完成 的完整路径。整个框架没有黑魔法,核心就是 while 循环 + 消息历史 + 工具注册表,剩下的都是工程细节。</p>
<ul>
<li>第七站:Core 核心代码深度拆解</li>
</ul>
<p>上一部分走完了从 CLI 输入到任务完成的主线流程。这一节把 AiCli.Core 里的核心抽象拆开来讲——这些是让整个框架能灵活扩展的骨架。</p>
<ul>
<li>7.1 类型系统:ContentPart 判别联合</li>
</ul>
<p>整个框架的数据基础是 ContentPart。LLM 返回的东西五花八门——普通文本、思考过程、工具调用请求、代码执行结果——全部统一成一棵继承树:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">classDiagram
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> TextContentPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> ThinkingContentPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> FunctionCallPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> FunctionResponsePart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> InlineDataPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> ExecutableCodePart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> CodeExecutionResultPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> ThoughtPart
    ContentPart </span>&lt;|--<span style="color: rgba(0, 0, 0, 1)"> FileDataPart

    </span><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> ContentPart {
      </span>&lt;&lt;<span style="color: rgba(0, 0, 255, 1)">abstract</span> record&gt;&gt;<span style="color: rgba(0, 0, 0, 1)">
    }
    </span><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> TextContentPart {
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> Text
    }
    </span><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> ThinkingContentPart {
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> Text
    }
    </span><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> FunctionCallPart {
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> FunctionName
      </span>+<span style="color: rgba(0, 0, 0, 1)">Dictionary Arguments
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> Id
    }
    </span><span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> FunctionResponsePart {
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> FunctionName
      </span>+<span style="color: rgba(0, 0, 0, 1)">Dictionary Response
      </span>+<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> Id
    }</span></pre>
</div>
<p>为什么用 abstract record 而不是接口或 enum + data?因为 C# 的 record 天然支持值相等、解构和 with 表达式,配合 switch 模式匹配就是天然的判别联合:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 在 Agent.GenerateResponseWithToolsAsync 中,用模式匹配分流不同类型</span>
<span style="color: rgba(0, 0, 255, 1)">foreach</span> (<span style="color: rgba(0, 0, 255, 1)">var</span> part <span style="color: rgba(0, 0, 255, 1)">in</span><span style="color: rgba(0, 0, 0, 1)"> candidate.Content)
{
    </span><span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)"> (part)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">case</span> ThinkingContentPart tp:<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 思考 token → 实时推给 UI</span>
<span style="color: rgba(0, 0, 0, 1)">            EmitEvent(AgentEventType.Thinking, tp.Text);
            </span><span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
      </span><span style="color: rgba(0, 0, 255, 1)">case</span> TextContentPart tx:      <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 普通文本 → 累积</span>
<span style="color: rgba(0, 0, 0, 1)">            textChunks.Add(tx.Text);
            </span><span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
      </span><span style="color: rgba(0, 0, 255, 1)">case</span> FunctionCallPart fc:   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 工具调用 → 收集起来待执行</span>
<span style="color: rgba(0, 0, 0, 1)">            toolCalls.Add(fc);
            </span><span style="color: rgba(0, 0, 255, 1)">break</span><span style="color: rgba(0, 0, 0, 1)">;
    }
}</span></pre>
</div>
<p>框架还提供了一个 Match&lt;T&gt; 扩展方法做穷举匹配,未处理的类型会直接抛异常,编译期虽然不能强制检查,但运行时不会漏掉:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">var</span> display =<span style="color: rgba(0, 0, 0, 1)"> part.Match(
    textHandler: t </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> t.Text,
    functionCallHandler: fc </span>=&gt; $<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">调用 {fc.FunctionName}</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">,
    thoughtHandler: th </span>=&gt; $<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">[思考] {th.Thought}</span><span style="color: rgba(128, 0, 0, 1)">"</span>
    <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 缺少其他 handler → 运行时 InvalidOperationException</span>
);</pre>
</div>
<ul>
<li>7.2 工具体系:三层抽象</li>
</ul>
<p>工具系统是整个框架代码量最大的部分,分三层:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 0, 1)">IToolBuilder          → 注册表用:提供名称、Schema、构建能力
└─ DeclarativeTool→ 基类:参数验证 </span>+<span style="color: rgba(0, 0, 0, 1)"> Schema 生成
       └─ ShellTool   → 具体工具:实际执行逻辑

IToolInvocation       → 执行器用:一次已验证的工具调用
└─ BaseToolInvocation → 基类:确认、描述、位置信息
       └─ ShellToolInvocation → 具体执行:启动进程、收集输出</span></pre>
</div>
<p>第一层:DeclarativeTool&lt;TParams, TResult&gt; — 声明式工具基类<br>每个工具继承它,只需要做三件事:定义参数 Schema、写验证逻辑、创建 Invocation:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Core/Tools/Builtin/ShellTool.cs</span>
<span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">class</span> ShellTool : DeclarativeTool&lt;ShellToolParams, ToolExecutionResult&gt;<span style="color: rgba(0, 0, 0, 1)">
{
    </span><span style="color: rgba(0, 0, 255, 1)">public</span> ShellTool(<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)"> targetDirectory)
      : </span><span style="color: rgba(0, 0, 255, 1)">base</span><span style="color: rgba(0, 0, 0, 1)">(
            ToolName,             </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> "shell"</span>
            DisplayName,          <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> "Shell"</span>
            Description,          <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> "Execute a shell command."</span>
            ToolKind.Execute,   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 分类:执行类操作</span>
            GetParameterSchema()) <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> JSON Schema 定义</span>
<span style="color: rgba(0, 0, 0, 1)">    { }

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 参数验证</span>
    <span style="color: rgba(0, 0, 255, 1)">protected</span> <span style="color: rgba(0, 0, 255, 1)">override</span> <span style="color: rgba(0, 0, 255, 1)">string</span>?<span style="color: rgba(0, 0, 0, 1)"> ValidateToolParams(ShellToolParams parameters)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">if</span> (<span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">.IsNullOrWhiteSpace(parameters.Command))
            </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">The 'command' parameter must not be empty.</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">;
      </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">null</span><span style="color: rgba(0, 0, 0, 1)">;
    }

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 创建可执行的 Invocation</span>
    <span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">override</span> IToolInvocation&lt;ShellToolParams, ToolExecutionResult&gt;<span style="color: rgba(0, 0, 0, 1)"> Build(ShellToolParams parameters)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> resolvedPath = <span style="color: rgba(0, 0, 255, 1)">string</span><span style="color: rgba(0, 0, 0, 1)">.IsNullOrWhiteSpace(parameters.WorkingDir)
            </span>?<span style="color: rgba(0, 0, 0, 1)"> _targetDirectory
            : Path.GetFullPath(Path.Combine(_targetDirectory, parameters.WorkingDir</span>!<span style="color: rgba(0, 0, 0, 1)">));
      </span><span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ShellToolInvocation(parameters, resolvedPath, _logger);
    }
}</span></pre>
</div>
<p>Schema 用匿名对象定义,DeclarativeTool 基类自动序列化成 FunctionDeclaration 传给 LLM:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">object</span> GetParameterSchema() =&gt; <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)">
{
    type </span>= <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">object</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">,
    properties </span>= <span style="color: rgba(0, 0, 255, 1)">new</span> Dictionary&lt;<span style="color: rgba(0, 0, 255, 1)">string</span>, <span style="color: rgba(0, 0, 255, 1)">object</span>&gt;<span style="color: rgba(0, 0, 0, 1)">
    {
      { </span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">command</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">new</span> { type = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">string</span><span style="color: rgba(128, 0, 0, 1)">"</span>, description = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">The shell command to execute.</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)"> } },
      { </span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">working_dir</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">new</span> { type = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">string</span><span style="color: rgba(128, 0, 0, 1)">"</span>, description = <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Working directory.</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)"> } },
    },
    required </span>= <span style="color: rgba(0, 0, 255, 1)">new</span>[] { <span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">command</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)"> }
};</span></pre>
</div>
<p>第二层:BaseToolInvocation&lt;TParams, TResult&gt; — 执行单元<br>一个 Invocation 代表一次已验证、可直接执行的工具调用。它和 Builder 分离的好处是:验证逻辑只跑一次,执行可以被队列化、重试、取消:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 概念上:Builder 是工厂,Invocation 是产品</span>
<span style="color: rgba(0, 0, 255, 1)">var</span> tool = registry.GetTool(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">shell</span><span style="color: rgba(128, 0, 0, 1)">"</span>);            <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> IToolBuilder</span>
<span style="color: rgba(0, 0, 255, 1)">var</span> invocation = tool.Build(arguments);         <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> IToolInvocation(已验证)</span>
<span style="color: rgba(0, 0, 255, 1)">var</span> result = <span style="color: rgba(0, 0, 255, 1)">await</span> executor.ExecuteAsync(invocation, options, ct);<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 执行</span></pre>
</div>
<p>第三层:LocalExecutor — 带超时和事件的执行器</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">async</span> Task&lt;ToolExecutionResult&gt;<span style="color: rgba(0, 0, 0, 1)"> ExecuteAsync(
    IToolInvocation invocation, ToolExecutionOptions options, CancellationToken ct)
{
    ToolExecutionStarted</span>?.Invoke(<span style="color: rgba(0, 0, 255, 1)">this</span>, <span style="color: rgba(0, 0, 255, 1)">new</span> { Invocation =<span style="color: rgba(0, 0, 0, 1)"> invocation });

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 带 5 分钟超时执行</span>
    <span style="color: rgba(0, 0, 255, 1)">var</span> timeoutTask =<span style="color: rgba(0, 0, 0, 1)"> Task.Delay(options.Timeout, ct);
    </span><span style="color: rgba(0, 0, 255, 1)">var</span> executionTask =<span style="color: rgba(0, 0, 0, 1)"> invocation.ExecuteAsync(ct, options.LiveOutputHandler);
    </span><span style="color: rgba(0, 0, 255, 1)">var</span> completed = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> Task.WhenAny(executionTask, timeoutTask);

    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (completed ==<span style="color: rgba(0, 0, 0, 1)"> timeoutTask)
      </span><span style="color: rgba(0, 0, 255, 1)">throw</span> <span style="color: rgba(0, 0, 255, 1)">new</span> TimeoutException($<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">Tool timed out after {options.Timeout.TotalSeconds}s</span><span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(0, 0, 0, 1)">);

    </span><span style="color: rgba(0, 0, 255, 1)">var</span> result = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> executionTask;
    ToolExecutionCompleted</span>?.Invoke(<span style="color: rgba(0, 0, 255, 1)">this</span>, <span style="color: rgba(0, 0, 255, 1)">new</span> { Invocation = invocation, Result =<span style="color: rgba(0, 0, 0, 1)"> result });
    </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> result;
}</span></pre>
</div>
<p>ToolKind 枚举决定了工具的安全等级,通过扩展方法判断是否需要用户确认:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">bool</span> RequiresConfirmation(<span style="color: rgba(0, 0, 255, 1)">this</span> ToolKind kind) =&gt; kind <span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)">
{
    ToolKind.Execute or ToolKind.Edit or ToolKind.Delete or ToolKind.Move </span>=&gt; <span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
    _ </span>=&gt; <span style="color: rgba(0, 0, 255, 1)">false</span><span style="color: rgba(0, 0, 0, 1)">
};

</span><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">static</span> <span style="color: rgba(0, 0, 255, 1)">bool</span> IsReadOnly(<span style="color: rgba(0, 0, 255, 1)">this</span> ToolKind kind) =&gt; kind <span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)">
{
    ToolKind.Read or ToolKind.Search or ToolKind.Think or ToolKind.Plan or ToolKind.Fetch </span>=&gt; <span style="color: rgba(0, 0, 255, 1)">true</span><span style="color: rgba(0, 0, 0, 1)">,
    _ </span>=&gt; <span style="color: rgba(0, 0, 255, 1)">false</span><span style="color: rgba(0, 0, 0, 1)">
};</span></pre>
</div>
<ul>
<li>7.3 LLM 通信层:OllamaContentGenerator</li>
</ul>
<p>这一层负责把框架内部的 ContentMessage 列表翻译成 Ollama API 的 JSON 格式,再把响应翻译回来。流式场景的处理最复杂:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> src/AiCli.Core/Chat/OllamaContentGenerator.cs — 流式响应处理</span>
<span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">async</span> IAsyncEnumerable&lt;GenerateContentResponse&gt;<span style="color: rgba(0, 0, 0, 1)"> GenerateContentStreamAsync(...)
{
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 逐行读取 NDJSON 流</span>
    <span style="color: rgba(0, 0, 255, 1)">while</span> (!<span style="color: rgba(0, 0, 0, 1)">reader.EndOfStream)
    {
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> line = <span style="color: rgba(0, 0, 255, 1)">await</span><span style="color: rgba(0, 0, 0, 1)"> reader.ReadLineAsync();
      </span><span style="color: rgba(0, 0, 255, 1)">using</span> <span style="color: rgba(0, 0, 255, 1)">var</span> doc =<span style="color: rgba(0, 0, 0, 1)"> JsonDocument.Parse(line);
      </span><span style="color: rgba(0, 0, 255, 1)">var</span> root =<span style="color: rgba(0, 0, 0, 1)"> doc.RootElement;

      </span><span style="color: rgba(0, 0, 255, 1)">if</span> (root.TryGetProperty(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">done</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">out</span> <span style="color: rgba(0, 0, 255, 1)">var</span> doneEl) &amp;&amp;<span style="color: rgba(0, 0, 0, 1)"> doneEl.GetBoolean())
            </span><span style="color: rgba(0, 0, 255, 1)">yield</span> <span style="color: rgba(0, 0, 255, 1)">break</span>;<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 流结束</span>

      <span style="color: rgba(0, 0, 255, 1)">var</span> parts = <span style="color: rgba(0, 0, 255, 1)">new</span> List&lt;ContentPart&gt;<span style="color: rgba(0, 0, 0, 1)">();

      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 1. Ollama 原生 thinking 字段(gpt-oss:20b, qwen3)</span>
      <span style="color: rgba(0, 0, 255, 1)">if</span> (messageEl.TryGetProperty(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">thinking</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">out</span> <span style="color: rgba(0, 0, 255, 1)">var</span><span style="color: rgba(0, 0, 0, 1)"> thinkingEl))
            parts.Add(</span><span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> ThinkingContentPart(thinkingEl.GetString()));

      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 2. content 字段可能含 &lt;think&gt; 标签(deepseek-r1 风格)</span>
      <span style="color: rgba(0, 0, 255, 1)">if</span> (messageEl.TryGetProperty(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">content</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">out</span> <span style="color: rgba(0, 0, 255, 1)">var</span><span style="color: rgba(0, 0, 0, 1)"> contentEl))
            ExtractThinkTagsIntoPartsInPlace(contentEl.GetString(), parts);

      </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 3. 工具调用</span>
      <span style="color: rgba(0, 0, 255, 1)">if</span> (messageEl.TryGetProperty(<span style="color: rgba(128, 0, 0, 1)">"</span><span style="color: rgba(128, 0, 0, 1)">tool_calls</span><span style="color: rgba(128, 0, 0, 1)">"</span>, <span style="color: rgba(0, 0, 255, 1)">out</span> <span style="color: rgba(0, 0, 255, 1)">var</span><span style="color: rgba(0, 0, 0, 1)"> toolCallsEl))
            </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> ... 解析 function name + arguments</span>

      <span style="color: rgba(0, 0, 255, 1)">yield</span> <span style="color: rgba(0, 0, 255, 1)">return</span> <span style="color: rgba(0, 0, 255, 1)">new</span> GenerateContentResponse { Candidates =<span style="color: rgba(0, 0, 0, 1)"> ... };
    }
}</span></pre>
</div>
<p>思考内容有两种输出格式需要兼容:<br>•        Ollama 原生字段:{"message":{"thinking":"...","content":"..."}}(gpt-oss:20b 开启 think:true)<br>•        XML 标签内嵌:{"message":{"content":"&lt;think&gt;推理过程&lt;/think&gt;最终回答"}}(deepseek-r1 风格)<br>ExtractThinkTagsIntoPartsInPlace 方法用字符串扫描把 &lt;think&gt;...&lt;/think&gt; 拆成 ThinkingContentPart 和 TextContentPart 交替序列,处理了未闭合标签(流式传输中常见)的边界情况。</p>
<ul>
<li>7.4 多模型编排:MultiModelOrchestrator</li>
</ul>
<p>本地跑 Ollama 时,不同任务适合不同模型。MultiModelOrchestrator 内部持有三个 OllamaContentGenerator 实例:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">sealed</span> <span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)"> MultiModelOrchestrator : IContentGenerator, IAsyncDisposable
{
    </span><span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">readonly</span> OllamaContentGenerator _embeddingGenerator;<span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> bge-m3</span>
    <span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">readonly</span> OllamaContentGenerator _thinkingGenerator;   <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> gpt-oss:20b (think:true)</span>
    <span style="color: rgba(0, 0, 255, 1)">private</span> <span style="color: rgba(0, 0, 255, 1)">readonly</span> OllamaContentGenerator _fastGenerator;       <span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> qwen2.5-coder:7b</span>

    <span style="color: rgba(0, 0, 255, 1)">public</span> IContentGenerator GetGenerator(ModelRole role) =&gt; role <span style="color: rgba(0, 0, 255, 1)">switch</span><span style="color: rgba(0, 0, 0, 1)">
    {
      ModelRole.Embedding </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> _embeddingGenerator,
      ModelRole.Thinking</span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> _thinkingGenerator,
      ModelRole.Fast      </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> _fastGenerator,
      _                   </span>=&gt;<span style="color: rgba(0, 0, 0, 1)"> _thinkingGenerator,
    };
}</span></pre>
</div>
<p>它对外实现 IContentGenerator 接口,默认委托给思考模型,保持向后兼容。但 Agent 场景会显式取快速模型:</p>
<div class="cnblogs_code">
<pre><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> AgentCommand.RegisterToolsAndAgents 中</span>
<span style="color: rgba(0, 0, 255, 1)">if</span> (contentGenerator <span style="color: rgba(0, 0, 255, 1)">is</span><span style="color: rgba(0, 0, 0, 1)"> MultiModelOrchestrator mmo)
    agentGen </span>= mmo.GetGenerator(ModelRole.Fast);</pre>
</div>
<p>&nbsp;</p>
<ul>
<li>7.5 Agent 类型体系</li>
</ul>
<p>四种内置 Agent 继承同一个 Agent 基类,区别只在 系统指令(System Instruction) 和 能力标签(Capabilities):</p>
<p><img src="https://img2024.cnblogs.com/blog/1099890/202603/1099890-20260317231419030-1283950947.png"></p>
<p>&nbsp;</p>
<p>它们的 ExecuteToolAsync 实现完全相同——都是 LocalExecutor + ApprovalMode.Auto。真正的差异化来自 GetSystemInstruction() 返回的 prompt,这决定了 LLM 在 ReAct 循环中的行为模式。<br>PlanAgent 额外提供了 CreatePlanAsync / ValidatePlanAsync 等方法,支持独立的计划-验证工作流,不一定要走完整的 ReAct 循环。</p>
<p>&nbsp;</p>
<p>最后:</p>
<p>这本质上是一个学习性质的实现——把 ReAct 模式的每个环节用 C# 从零写了一遍,踩了不少坑(比如 qwen 模型不走原生 tool_calls、思考模型带工具时卡住、snake_case 参数转换等),这些经验可能比代码本身更有价值。</p>
<p>当前项目可能需要更加适合的模型,根据模型的特点来调整流程才能往一点点的往前看,当前Caude、Copilot等成熟的cli和客户端加上自己的大模型已经让大家非常痛快的掏钱去投入实际的开发中,有没有必要继续研究智能体的开发是一个很头痛的问题。</p>
<p>庆幸的是现在有好多开源的智能体的项目,也是有做各种参考学习。AI真的来了,作为程序员喜忧参半,未来影响怎么样拭目以待吧!</p>

</div>
<div id="MySignature" role="contentinfo">
    <img loading="lazy" width="149" height="149" src="https://github.blog/wp-content/uploads/2008/12/forkme_left_darkblue_121621.png?resize=149%2C149" class="attachment-full size-full" alt="Fork me on GitHub" data-recalc-dims="1"><br><br>
来源:https://www.cnblogs.com/morec/p/19731582
頁: [1]
查看完整版本: 用 C# 写一个完整的 ReAct 智能体:从命令行输入到任务完成的全链路拆解