ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于.NET 8与React构建企业级AI代理框架:从工具编排到安全落地

基于.NET 8与React构建企业级AI代理框架:从工具编排到安全落地

1. 为什么用 .NET 8.0 + React 构建“Manus 式”的 AI 代理框架

1.1 企业要的并不是聊天,而是“能自己干完活”

标题里提到的 Manus,代表的是一类“自主代理”的产品形态:给它一个目标,它自己拆解步骤、调用工具、核对结果,最后交付一份可用的成果。企业内部对这个能力的期待,早就不是“聊得顺”,而是“活能干完”。

我今年搭了一个内部项目的原型,项目代号就叫“某智能助理代理项目”。业务方最初的描述特别具体:用户说一句“把上周华北区所有未发货订单汇总成报表”,系统要自己查订单库、调客户信息、生成 Excel、再推送到审批队列。注意,这中间没有人为写死流程,全部由代理动态规划。这个需求如果只靠普通聊天机器人接口,根本做不出来。

于是问题变成:用什么技术栈来托住这个“代理”?我最终选型是后端 .NET 8.0,前端 React。这篇内容就是把这套框架从选型到落地、再到踩坑的全过程复盘。适合正在做企业级 AI 应用架构、或者准备在内部业务系统里引入自主代理的后端工程师和全栈团队参考。

1.2 .NET 8.0 的编排优势:统一模型抽象与强类型工具定义

很多人听到 AI 代理,第一反应是 Python 生态。这个判断在快速做原型时没错,但放到企业生产环境,我观察到另一个事实:公司里大量的订单、人资、财务系统都是 .NET 技术栈,代理要调用这些内部系统,天生要用 C# 对接。与其在 Python 侧用 HTTP 硬拼接口,不如让代理编排层直接长在业务系统旁边。

.NET 8.0 还有一个关键变化:微软把 AI 客户端抽象做进了扩展库,也就是Microsoft.Extensions.AI包。它提供统一的IChatClient接口,可以对接 OpenAI 兼容协议、Azure OpenAI、Ollama 等不同的模型提供方。这意味着代理编排层不需要绑定某一家模型厂商,切换模型时只改配置,业务代码不动。

另一个容易被低估的点是强类型。工具调用需要把模型返回的 JSON 参数反序列化成真实方法参数,C# 的强类型和System.Text.Json配合,天然比动态语言多一道编译期保障。代理框架里最怕的就是工具参数解析出错,一个字段名对不上,轻则空参数调用,重则把错误数据写进业务库。强类型能挡掉不少这类的低级故障。

1.3 React 在前端的定位:渲染“执行过程”而不是渲染一句话

企业级代理的交互和普通聊天完全不同。普通聊天是用户发一句、模型回一句;代理执行可能是几十个步骤:生成计划、调用三个工具、中途要人工审批、最后汇总文档。前端如果只按聊天气泡渲染,用户完全不知道系统在干什么,也不知道为什么卡住了。

React 在这个场景下的优势很明确:组件化适合把“工具调用”渲染成一块独立的卡片,状态管理适合做任务状态机,生态里又有成熟的 Markdown 渲染和虚拟滚动方案,处理长流程时不会卡界面。配合 TypeScript 的 discriminated union,我们可以把后端推过来的各种事件类型收拢成一个可枚举的联合类型,前端状态机永远只处理已知状态,出现未知事件直接报错而不是瞎渲染。

所以最终架构的本质是:.NET 8.0 做代理编排和执行中枢,React 做实时过程可视化界面。下面从后端骨架开始讲。

2. 代理主循环与工具注册表:先把骨架搭起来

2.1 最小可用的模型调用循环

代理最核心的机制是一个循环:把消息历史交给模型,模型决定是返回最终答案还是请求调用工具;如果调工具,就执行工具并把结果回传给模型,继续下一轮。

我用Microsoft.Extensions.AI的实现来写这一段,逻辑非常直白:

var chatClient = services.GetRequiredService<IChatClient>(); var messages = new List<ChatMessage> { new(ChatRole.System, "你是企业业务代理,谨慎调用工具。"), new(ChatRole.User, prompt) }; var options = new ChatOptions { Tools = [queryOrderTool, createReportTool], MaxIterations = 10 // 防止模型陷入无限工具调用 }; var response = await chatClient.GetResponseAsync(messages, options, cancellationToken);

这里有一个隐藏的关键参数:MaxIterations。AI 代理在生产环境里的第一大隐患就是“工具调用循环不退出”,模型反复调工具、看着结果、再调工具,直到把上下文窗口打满。这个参数本质上是给循环加一把安全闸。

但需要注意,MaxIterations控制的只是模型响应轮数,工具的独立执行时长还需要在工具内部单独限制。关于超时,我后面专门有一个章节讲,先不展开。

2.2 工具注册表:把强类型方法变成模型可调用的函数

有了主循环,下一步是让模型能够调用业务方法。我的做法是定义一个注册表,程序启动时扫描程序集里带[AgentTool]标记的方法,自动生成函数描述,供模型选择。

[AgentTool("查询订单状态", "根据订单号查询当前状态、物流信息和金额")] public async Task<OrderInfo> QueryOrderStatus(string orderId) { // 内部调用订单服务 API } [AgentTool("生成销售报表", "按时间范围和区域汇总销售额并生成 Excel 文件")] public async Task<ReportResult> GenerateSalesReport(DateTime start, DateTime end, string? region = null) { // 业务实现 }

注册表扫描后,通过AIFunctionFactory.Create把方法包装成模型可用的工具描述:

var queryTool = AIFunctionFactory.Create( typeof(ToolRegistry).GetMethod(nameof(ToolRegistry.QueryOrderStatus)), "查询订单状态", "根据订单号查询当前状态、物流信息和金额");

AIFunctionFactory.Create会自动根据方法签名生成 JSON Schema,包括参数名、类型、默认值。这里我强烈建议:方法的参数要保持原始类型,不要接收 JObject 或 JsonElement。粗看是灵活,实际是把参数校验责任丢给了模型输出,代理框架里这是最危险的偷懒方式。

2.3 会话、任务树与记忆策略

代理跑起来之后,第二个问题是怎么记录“它到底做了什么”。生产环境里,业务方一定会问“这个结论是怎么来的”。我们需要一张任务树,把用户请求拆成父任务和子任务,每次工具调用都挂在某个节点下面。

我设计的核心实体有三张表:

  • AgentSession:一次用户会话,包含基础 prompt、模型配置、会话状态。
  • AgentTask:一个目标的一次执行,父子结构,记录目标、当前状态、消耗 token。
  • AgentStep:任务里的每一步,尤其是工具调用的入参、出参摘要、耗时。

记忆策略上,我建议先做会话窗口加摘要压缩,不要一上来就上向量记忆。原因很简单:企业代理的大部分场景是流程编排,上下文窗口足够装下几十步操作;只有当需要跨会话引用历史结论时,向量记忆才有实际价值。过早引入向量库,只会让排查问题多一个变量。

3. 连接企业系统:把工具层做成可控的“插件式”能力

3.1 按风险等级拆分工具,而不是一刀切让代理全自动

企业系统和公开 API 最大的区别是:很多操作有副作用,不能允许 AI 凭空决定。我最初把工具全部设为自动执行,结果测试时模型直接把一条测试订单的状态改成了“已发货”,虽然数据是假的,但足够让人后背发凉。

后来我把工具分成三类,通过元数据标注:

工具类别说明执行策略
查询类只读操作,如查订单、查库存自动执行
写操作类会修改数据,如更新状态、创建工单需要用户确认或审批人确认
外部审批类会触发跨系统流程,如发起付款、推送公告必须经过审批人操作

[AgentTool]特性里增加一个ExecutionMode枚举字段,编排器在执行工具前先判级。如果是写操作类,代理先把参数展示给用户确认,用户点了“允许”才真正执行。

这一步不是技术难题,但几乎决定了项目能不能上线。企业里真正担心的不是 AI 蠢,而是 AI 在没人确认的情况下做了不可逆的操作。宁可在交互上多一步确认,也不要省这个保险。

3.2 工具执行时如何正确传递用户上下文

工具调用还有一个容易栽的坑:代理服务用自己的应用账号去调业务接口。这意味着所有操作都变成“系统行为”,一旦出了问题,审计日志里查不到责任人。

正确做法是,工具方法内部必须能拿到当前真实用户身份。我的实现是定义一个AgentExecutionContext,在 REQUEST 到达时填充,工具方法通过参数注入访问:

public sealed class AgentExecutionContext { public Guid UserId { get; init; } public string? UserName { get; init; } } [AgentTool("更新订单状态", "更新指定订单的物流状态")] public async Task UpdateOrderStatus(AgentExecutionContext ctx, string orderId, string newStatus) { await orderService.UpdateAsync(orderId, newStatus, operatorId: ctx.UserId); }

注意,用户身份绝对不能由模型生成,而是从认证中间件里拿到的ClaimsPrincipal映射而来。模型只能决定“做什么”,框架必须决定“以谁的身份做”。

3.3 提示注入、越权与参数白名单的三道防线

接入了业务工具之后,代理的安全模型远比普通聊天复杂。首先是提示注入:模型在阅读外部文档、邮件正文时,可能把里面的“指令”当成系统指令,进而诱导工具调用。这个问题的缓解不能靠模型自律,要靠框架隔离。

我做了三道防线:

第一,工具描述里不暴露内部系统细节。比如查询接口真实表名是ord_2024_archive,工具描述里只写“查询订单”,这样模型就算想注入也没有可用的字段名。

第二,参数白名单校验。所有工具方法的参数在执行前经过一次校验器,只允许符合业务规则的输入。比如查询报表的时间范围,限制最长跨度 90 天,防止模型传一个“1900 年到 2099 年”的荒谬区间把数据库拖垮。

第三,执行前二次鉴权。写操作类工具在执行前,除了要过审批,还要在服务端重新校验角色权限,不能只依赖前端按钮隐藏。权限判断必须发生在后端,因为前端展示什么根本不能当作安全边界。

4. React 实时协作界面:事件驱动的执行时间线

4.1 后端推送哪些事件,以及为什么选 SignalR

代理的执行过程是半异步的,前端不能干等一次 HTTP 响应。我评估过两种方案:SSE 和 SignalR。SSE 适合单方向文本流,实现简单;但代理场景里前端还需要向前端发送“停止任务”“提交审批决策”这类控制指令,双向通信更顺手。所以最终选了 SignalR。

后端把代理执行的各个节点包装成结构化事件:

public abstract record AgentEventBase(string EventId, string TaskId, DateTimeOffset Timestamp); public sealed record TaskStartedEvent(string EventId, string TaskId, DateTimeOffset Timestamp, string Goal, string[] Plan) : AgentEventBase(EventId, TaskId, Timestamp); public sealed record MessageDeltaEvent(string EventId, string TaskId, DateTimeOffset Timestamp, string Delta) : AgentEventBase(EventId, TaskId, Timestamp); public sealed record ToolCallStartedEvent(string EventId, string TaskId, DateTimeOffset Timestamp, string ToolName, string ArgumentsJson) : AgentEventBase(EventId, TaskId, Timestamp); public sealed record ToolCallCompletedEvent(string EventId, string TaskId, DateTimeOffset Timestamp, string ToolName, string ResultSummary) : AgentEventBase(EventId, TaskId, Timestamp); public sealed record ApprovalRequiredEvent(string EventId, string TaskId, DateTimeOffset Timestamp, string ToolName, string ArgumentsJson) : AgentEventBase(EventId, TaskId, Timestamp);

Hub 端按会话分组推送:

public class AgentHub : Hub { public async Task SubscribeSession(string sessionId) { await Groups.AddToGroupAsync(Context.ConnectionId, $"session-{sessionId}"); } public async Task SendApprovalDecision(string sessionId, string taskId, string toolCallId, bool approved) { // 略:从后台任务队列中取出待审批的工具调用,按决策放行或终止 } }

推送时机要尽量细。尤其是工具调用,用户希望看到“正在执行哪个工具、参数是什么、返回了什么”的完整过程。透明度越高,信任度越高。

4.2 前端状态模型:用 discriminated union 管理代理状态

前端如果沿用聊天消息数组,会遇到一个很现实的问题:流式文本增量、工具事件、审批事件混在一起,UI 状态会越来越不可控。我用 discriminated union 把所有事件收拢成一个联合类型:

export type AgentEvent = | { kind: 'task-started'; eventId: string; taskId: string; goal: string; plan: string[] } | { kind: 'message-delta'; eventId: string; taskId: string; delta: string } | { kind: 'tool-started'; eventId: string; taskId: string; toolName: string; argumentsJson: unknown } | { kind: 'tool-completed'; eventId: string; taskId: string; toolName: string; resultSummary: string } | { kind: 'approval-required'; eventId: string; taskId: string; toolName: string; argumentsJson: unknown };

事件收进来后,交给useReducer处理:

type AgentState = { taskStatus: 'queued' | 'running' | 'waiting-approval' | 'completed' | 'failed'; plan: string[]; messages: string[]; toolCalls: ToolCallCard[]; }; function agentReducer(state: AgentState, event: AgentEvent): AgentState { switch (event.kind) { case 'task-started': return { ...state, taskStatus: 'running', plan: event.plan }; case 'message-delta': // 追加到当前消息尾部 return { ...state, messages: [...state.messages, event.delta] }; case 'tool-start': // 往执行时间线中增加一张卡 return { ...state, toolCalls: [...state.toolCalls, { ...event, status: 'running' }] }; case 'approval-required': return { ...state, taskStatus: 'waiting-approval' }; default: return state; } }

这个模型的优势是,每个事件都天然对应一个 UI 节点,用户能按时间顺序看到整个代理的工作记录。相比“最终弹出一段答案”,这个时间线让系统的行为完全透明。

4.3 工具调用卡片的渲染与人工审批入口

前端 UI 上,每个工具调用渲染成一张卡片,包含工具名、参数摘要、执行状态。执行中的卡片显示 spinner,完成的卡片显示结果摘要,需要审批的卡片显示【允许】【拒绝】两个按钮。

审批按钮触发connection.invoke('SendApprovalDecision', ...),后端收到决策后,继续或终止代理任务。这里有个体验细节:审批事件需要持久化。用户可能在手机上看了一眼、没有立刻决策,关掉页面;代理任务应该继续挂起,而不是随着浏览器关闭而消失。重新打开页面时,前端需要从服务端拉取这个会话的事件历史,恢复到上次的界面状态。

我踩的坑是把审批决策做成了“前端状态”,结果用户刷新页面后,待审批的工具调用消失了。后来改成:审批事件全部落库,前端初始加载时先查询“进行中的任务”并重建时间线,才把这个洞补上。

5. 企业级落地:身份、审计、可观测性与部署

5.1 认证之后的关键一步:代理以谁的身份执行

企业级应用逃不开认证授权。前端用 JWT Bearer Token 访问 API,SignalR 连接也必须在negotiate请求里带上 token。但这只是门禁,真正重要的是把“谁发起的任务”一路透传到工具执行层。

我在中间件里拿到ClaimsPrincipal后,把它写进一个AsyncLocal<AgentExecutionContext>:

public sealed class AgentUserContextMiddleware { public async Task InvokeAsync(HttpContext context, IServiceProvider sp) { var ctx = new AgentExecutionContext { UserId = Guid.Parse(context.User.FindFirst("sub")!.Value) }; using var scope = AgentExecutionContext.Scope(ctx); await _next(context); } }

工具方法通过注入拿到当前用户 ID。这么做最重要的意义是:审计日志里能明确记录“是谁要求代理做了这件事”。我在某个版本里偷懒,让代理用服务账号调接口,被安全团队直接打回,就是因为无法回答“谁发起的变更”。

5.2 审计日志的事件设计

代理框架的审计不能只记最终结果,要记每一步决策。我设计了一张agent_audit_log表,核心字段如下:

字段说明
event_id全局唯一事件 ID
trace_id分布式链路 ID
session_id会话 ID
task_id任务 ID
actor_id真实用户 ID
event_type事件类型,如TOOL_CALL、APPROVE、REJECT
tool_name工具名
arguments_json工具调用参数原文
result_summary执行结果摘要
token_usage该步骤消耗的 token 数
created_at事件时间

注意arguments_json一定要存原文。这不仅是为了审计追溯,也是日后排查问题的关键依据。当业务方质疑“为什么代理会产生这个操作”时,你可以拿原始参数重建当时的完整上下文。

5.3 可观测性:埋点先于上线

代理系统的观测比普通接口复杂,因为它包含模型调用、工具调用、审批等待三段不同性质的延迟。我用 OpenTelemetry 做统一埋点,每个任务创建一个新的 Activity:

using var activity = _activitySource.StartActivity("agent.task"); activity?.SetTag("agent.task_id", taskId); activity?.SetTag("agent.goal", goal); var toolActivity = _activitySource.StartActivity("agent.tool_call"); toolActivity?.SetTag("agent.tool_name", toolName); toolActivity?.SetTag("agent.tool_args", argsJson);

我建议重点关注的指标包括:

  • agent.task.total_duration:任务总时长
  • agent.task.step_count:任务经历的工具调用次数
  • agent.tool_call.error_total:工具调用失败次数
  • agent.llm.token_usage.total:token 消耗
  • agent.approval.wait_duration:审批等待时长

这些指标能直接回答业务方最爱问的三个问题:为什么这么慢、为什么失败、花了多少钱。埋点应该在上线前就做好,不要等出事故再补,因为补埋点意味着重新发布代码,排障窗口早就过了。

5.4 部署形态与运行环境配置

部署上我采用了一个 .NET 进程同时托管 API 和前端静态文件的方案,适合中小规模团队,部署成本最低:

# 后端镜像 FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/out . # 前端静态文件 COPY ./webapp/dist ./wwwroot EXPOSE 8080 ENV ASPNETCORE_URLS=http://+:8080 ENTRYPOINT ["dotnet", "Agent.Api.dll"]

如果前端团队希望独立发布,也可以拆成 Nginx 静态站点反向代理到 .NET 服务。两种方案我都跑过,后者适合前端独立迭代,前者适合内部工具型产品快速上线。

环境配置方面,模型端点、密钥、审批超时时长都放到环境变量或配置中心,不要写死在代码里。模型网关通常部署在内网,代理服务通过配置指定端点地址,换模型时只改配置、不动代码,这样切换成本会低得多。

6. 实测踩坑记录:超时、重试、并发、测试

6.1 超时不是设一个 HttpClient.Timeout 就完事

代理链路里的超时必须分层。我最初只给 LLM 调用设置了一个 100 秒的 HttpClient 超时,结果工具执行卡在数据库查询上,拖到最后整体超时,用户看到的是“任务失败”,但代理到底执行到哪一步完全不知道。

正确的做法是分层设置,并且用CancellationToken串起来:

// 任务级总超时 using var taskCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); // 模型调用子超时 using var modelCts = CancellationTokenSource.CreateLinkedTokenSource( taskCts.Token, CancellationToken.None); modelCts.CancelAfter(TimeSpan.FromSeconds(90)); var response = await chatClient.GetResponseAsync(messages, options, modelCts.Token); // 工具调用子超时 using var toolCts = CancellationTokenSource.CreateLinkedTokenSource( taskCts.Token, CancellationToken.None); toolCts.CancelAfter(TimeSpan.FromSeconds(30)); var result = await tool.ExecuteAsync(toolCts.Token);

总超时是保命的,子超时是让问题尽早暴露的。不要让一个慢工具拖着整个任务进入不可控状态,也不要让任务总时长无限延长。生产环境里,宁可任务超时失败,也不能让它挂在后台占着资源。

6.2 工具重试必须区分只读和写操作

模型调用可能会因网络抖动失败,工具执行也可能会失败。但重试策略绝对不能一刀切。

工具类型失败后的重试策略
查询类自动重试最多 2 次,指数退避
写操作类不自动重试,直接标记任务失败,进入人工排查
外部审批类不重试,等待审批决策后继续

为什么写操作不自动重试?因为写操作一旦执行成功,但网络响应丢失,重试就会造成重复操作——比如重复创建工单、重复扣减库存。这个坑我在测试环境里踩过:模拟超时后重试,结果库里出现了两条一模一样的工单。从那以后,所有写操作类工具都加了幂等键,请求参数里带上idempotencyKey,服务端按这个键去重。

6.3 同一会话内的串行队列

AI 代理还存在一个隐蔽的并发问题:如果用户在 UI 上同时发起两个任务,后端并发执行,两个任务会共享同一个会话状态,导致上下文窗口里的消息顺序混乱。

我的处置是:会话级串行,任务级可控。具体实现是每个AgentSession持有一个SemaphoreSlim:

public sealed class SessionGate { private readonly SemaphoreSlim _gate = new(1, 1); public async Task<T> RunExclusiveAsync<T>(Func<Task<T>> action) { await _gate.WaitAsync(); try { return await action(); } finally { _gate.Release(); } } }

同一个会话的任务排队执行,不同会话的任务可以并行。这个设计既保护了上下文一致性,又不会因为一个用户的慢任务阻塞其他用户。

6.4 用假模型测试编排器的确定性

代理编排器的测试难点在于模型输出是随机的。我们不能依赖真实 LLM 做单元测试,否则测试结果不稳定,今天绿明天红。我的做法是自定义一个FakeChatClient,按预设顺序返回固定响应。

public sealed class FakeChatClient : IChatClient { private readonly Queue<ChatResponse> _responses = new(); public FakeChatClient(params ChatResponse[] responses) { foreach (var r in responses) { _responses.Enqueue(r); } } public Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { return Task.FromResult(_responses.Dequeue()); } }

测试用例可以设计成:第一次调用返回一个工具调用指令“查询订单状态”,第二次调用返回最终答案。断言编排器在收到工具结果后确实走了“继续调用模型”的流程,并且在第二次调用后正常收尾。这样测的是编排器的控制流,而不是模型的推理能力,稳定且快。

这类测试建议至少在三个层面覆盖:工具调用成功路径、工具调用失败路径、审批拒绝路径。审批拒绝是我发现最容易出 bug 的地方,因为它跨了前端、Hub、任务队列三层,缺一个环节就会卡死。

最后再分享一点个人感受:构建这套代理框架,难点从来不在于“让模型干活”,而在于给模型划定边界。ELO 自动权等级,我建议第一版所有写操作都走人工确认,跑上一个月,观察用户真正信任哪些工具可以放开,再逐步提权。不要被“全自动代理”的产品宣传牵着走,企业场景里,可解释、可中断、可审计,比“聪明”更有价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进