用 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<AgentResult> 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_file  Program.cs");       // 显示 ⠋ 旋转动画
            break;
        case AgentEventType.ToolCompleted:
            renderer.CompleteLast();                       // ⠋ → ✓
            break;
    }
};

终端效果类似 Claude Code 的任务列表:

复制代码
  ✓ ◆ 思考完成 (2.3s)
  ✓ shell  dotnet new console -n Calculator -o Calculator
  ✓ write_file  Program.cs
  ✓ shell  dotnet 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 {
        <<abstract record>>
    }
    class TextContentPart {
        +string Text
    }
    class ThinkingContentPart {
        +string Text
    }
    class FunctionCallPart {
        +string FunctionName
        +Dictionary Arguments
        +string Id
    }
    class FunctionResponsePart {
        +string FunctionName
        +Dictionary Response
        +string Id
    }

为什么用 abstract record 而不是接口或 enum + data?因为 C# 的 record 天然支持值相等、解构和 with 表达式,配合 switch 模式匹配就是天然的判别联合:

复制代码
// 在 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;
    }
}

框架还提供了一个 Match<T> 扩展方法做穷举匹配,未处理的类型会直接抛异常,编译期虽然不能强制检查,但运行时不会漏掉:

复制代码
var display = part.Match(
    textHandler: t => t.Text,
    functionCallHandler: fc => $"调用 {fc.FunctionName}",
    thoughtHandler: th => $"[思考] {th.Thought}"
    // 缺少其他 handler → 运行时 InvalidOperationException
);
  • 7.2 工具体系:三层抽象

工具系统是整个框架代码量最大的部分,分三层:

复制代码
IToolBuilder          → 注册表用:提供名称、Schema、构建能力
  └─ DeclarativeTool  → 基类:参数验证 + Schema 生成
       └─ ShellTool   → 具体工具:实际执行逻辑

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

第一层:DeclarativeTool<TParams, TResult> --- 声明式工具基类

每个工具继承它,只需要做三件事:定义参数 Schema、写验证逻辑、创建 Invocation:

复制代码
// src/AiCli.Core/Tools/Builtin/ShellTool.cs
public class ShellTool : DeclarativeTool<ShellToolParams, ToolExecutionResult>
{
    public ShellTool(string targetDirectory)
        : base(
            ToolName,             // "shell"
            DisplayName,          // "Shell"
            Description,          // "Execute a shell command."
            ToolKind.Execute,     // 分类:执行类操作
            GetParameterSchema()) // JSON Schema 定义
    { }

    // 参数验证
    protected override string? ValidateToolParams(ShellToolParams parameters)
    {
        if (string.IsNullOrWhiteSpace(parameters.Command))
            return "The 'command' parameter must not be empty.";
        return null;
    }

    // 创建可执行的 Invocation
    public override IToolInvocation<ShellToolParams, ToolExecutionResult> Build(ShellToolParams parameters)
    {
        var resolvedPath = string.IsNullOrWhiteSpace(parameters.WorkingDir)
            ? _targetDirectory
            : Path.GetFullPath(Path.Combine(_targetDirectory, parameters.WorkingDir!));
        return new ShellToolInvocation(parameters, resolvedPath, _logger);
    }
}

Schema 用匿名对象定义,DeclarativeTool 基类自动序列化成 FunctionDeclaration 传给 LLM:

复制代码
private static object GetParameterSchema() => new
{
    type = "object",
    properties = new Dictionary<string, object>
    {
        { "command", new { type = "string", description = "The shell command to execute." } },
        { "working_dir", new { type = "string", description = "Working directory." } },
    },
    required = new[] { "command" }
};

第二层:BaseToolInvocation<TParams, TResult> --- 执行单元

一个 Invocation 代表一次已验证、可直接执行的工具调用。它和 Builder 分离的好处是:验证逻辑只跑一次,执行可以被队列化、重试、取消:

复制代码
// 概念上:Builder 是工厂,Invocation 是产品
var tool = registry.GetTool("shell");            // IToolBuilder
var invocation = tool.Build(arguments);           // IToolInvocation(已验证)
var result = await executor.ExecuteAsync(invocation, options, ct);  // 执行

第三层:LocalExecutor --- 带超时和事件的执行器

复制代码
public async Task<ToolExecutionResult> ExecuteAsync(
    IToolInvocation invocation, ToolExecutionOptions options, CancellationToken ct)
{
    ToolExecutionStarted?.Invoke(this, new { Invocation = invocation });

    // 带 5 分钟超时执行
    var timeoutTask = Task.Delay(options.Timeout, ct);
    var executionTask = invocation.ExecuteAsync(ct, options.LiveOutputHandler);
    var completed = await Task.WhenAny(executionTask, timeoutTask);

    if (completed == timeoutTask)
        throw new TimeoutException($"Tool timed out after {options.Timeout.TotalSeconds}s");

    var result = await executionTask;
    ToolExecutionCompleted?.Invoke(this, new { Invocation = invocation, Result = result });
    return result;
}

ToolKind 枚举决定了工具的安全等级,通过扩展方法判断是否需要用户确认:

复制代码
public static bool RequiresConfirmation(this ToolKind kind) => kind switch
{
    ToolKind.Execute or ToolKind.Edit or ToolKind.Delete or ToolKind.Move => true,
    _ => false
};

public static bool IsReadOnly(this ToolKind kind) => kind switch
{
    ToolKind.Read or ToolKind.Search or ToolKind.Think or ToolKind.Plan or ToolKind.Fetch => true,
    _ => false
};
  • 7.3 LLM 通信层:OllamaContentGenerator

这一层负责把框架内部的 ContentMessage 列表翻译成 Ollama API 的 JSON 格式,再把响应翻译回来。流式场景的处理最复杂:

复制代码
// src/AiCli.Core/Chat/OllamaContentGenerator.cs --- 流式响应处理
public async IAsyncEnumerable<GenerateContentResponse> GenerateContentStreamAsync(...)
{
    // 逐行读取 NDJSON 流
    while (!reader.EndOfStream)
    {
        var line = await reader.ReadLineAsync();
        using var doc = JsonDocument.Parse(line);
        var root = doc.RootElement;

        if (root.TryGetProperty("done", out var doneEl) && doneEl.GetBoolean())
            yield break;  // 流结束

        var parts = new List<ContentPart>();

        // 1. Ollama 原生 thinking 字段(gpt-oss:20b, qwen3)
        if (messageEl.TryGetProperty("thinking", out var thinkingEl))
            parts.Add(new ThinkingContentPart(thinkingEl.GetString()));

        // 2. content 字段可能含 <think> 标签(deepseek-r1 风格)
        if (messageEl.TryGetProperty("content", out var contentEl))
            ExtractThinkTagsIntoPartsInPlace(contentEl.GetString(), parts);

        // 3. 工具调用
        if (messageEl.TryGetProperty("tool_calls", out var toolCallsEl))
            // ... 解析 function name + arguments

        yield return new GenerateContentResponse { Candidates = ... };
    }
}

思考内容有两种输出格式需要兼容:

• Ollama 原生字段:{"message":{"thinking":"...","content":"..."}}(gpt-oss:20b 开启 think:true)

• XML 标签内嵌:{"message":{"content":"<think>推理过程</think>最终回答"}}(deepseek-r1 风格)

ExtractThinkTagsIntoPartsInPlace 方法用字符串扫描把 <think>...</think> 拆成 ThinkingContentPart 和 TextContentPart 交替序列,处理了未闭合标签(流式传输中常见)的边界情况。

  • 7.4 多模型编排:MultiModelOrchestrator

本地跑 Ollama 时,不同任务适合不同模型。MultiModelOrchestrator 内部持有三个 OllamaContentGenerator 实例:

复制代码
public sealed class MultiModelOrchestrator : IContentGenerator, IAsyncDisposable
{
    private readonly OllamaContentGenerator _embeddingGenerator;  // bge-m3
    private readonly OllamaContentGenerator _thinkingGenerator;   // gpt-oss:20b (think:true)
    private readonly OllamaContentGenerator _fastGenerator;       // qwen2.5-coder:7b

    public IContentGenerator GetGenerator(ModelRole role) => role switch
    {
        ModelRole.Embedding => _embeddingGenerator,
        ModelRole.Thinking  => _thinkingGenerator,
        ModelRole.Fast      => _fastGenerator,
        _                   => _thinkingGenerator,
    };
}

它对外实现 IContentGenerator 接口,默认委托给思考模型,保持向后兼容。但 Agent 场景会显式取快速模型:

复制代码
// AgentCommand.RegisterToolsAndAgents 中
if (contentGenerator is MultiModelOrchestrator mmo)
    agentGen = mmo.GetGenerator(ModelRole.Fast);
  • 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真的来了,作为程序员喜忧参半,未来影响怎么样拭目以待吧!