尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于.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.GetRequiredServiceIChatClient(); var messages new ListChatMessage { 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 TaskOrderInfo QueryOrderStatus(string orderId) { // 内部调用订单服务 API } [AgentTool(生成销售报表, 按时间范围和区域汇总销售额并生成 Excel 文件)] public async TaskReportResult 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 访问 APISignalR 连接也必须在negotiate请求里带上 token。但这只是门禁真正重要的是把“谁发起的任务”一路透传到工具执行层。我在中间件里拿到ClaimsPrincipal后把它写进一个AsyncLocalAgentExecutionContextpublic 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全局唯一事件 IDtrace_id分布式链路 IDsession_id会话 IDtask_id任务 IDactor_id真实用户 IDevent_type事件类型如TOOL_CALL、APPROVE、REJECTtool_name工具名arguments_json工具调用参数原文result_summary执行结果摘要token_usage该步骤消耗的 token 数created_at事件时间注意arguments_json一定要存原文。这不仅是为了审计追溯也是日后排查问题的关键依据。当业务方质疑“为什么代理会产生这个操作”时你可以拿原始参数重建当时的完整上下文。5.3 可观测性埋点先于上线代理系统的观测比普通接口复杂因为它包含模型调用、工具调用、审批等待三段不同性质的延迟。我用 OpenTelemetry 做统一埋点每个任务创建一个新的 Activityusing 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.totaltoken 消耗agent.approval.wait_duration审批等待时长这些指标能直接回答业务方最爱问的三个问题为什么这么慢、为什么失败、花了多少钱。埋点应该在上线前就做好不要等出事故再补因为补埋点意味着重新发布代码排障窗口早就过了。5.4 部署形态与运行环境配置部署上我采用了一个 .NET 进程同时托管 API 和前端静态文件的方案适合中小规模团队部署成本最低# 后端镜像 FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --frombuild /app/out . # 前端静态文件 COPY ./webapp/dist ./wwwroot EXPOSE 8080 ENV ASPNETCORE_URLShttp://: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持有一个SemaphoreSlimpublic sealed class SessionGate { private readonly SemaphoreSlim _gate new(1, 1); public async TaskT RunExclusiveAsyncT(FuncTaskT action) { await _gate.WaitAsync(); try { return await action(); } finally { _gate.Release(); } } }同一个会话的任务排队执行不同会话的任务可以并行。这个设计既保护了上下文一致性又不会因为一个用户的慢任务阻塞其他用户。6.4 用假模型测试编排器的确定性代理编排器的测试难点在于模型输出是随机的。我们不能依赖真实 LLM 做单元测试否则测试结果不稳定今天绿明天红。我的做法是自定义一个FakeChatClient按预设顺序返回固定响应。public sealed class FakeChatClient : IChatClient { private readonly QueueChatResponse _responses new(); public FakeChatClient(params ChatResponse[] responses) { foreach (var r in responses) { _responses.Enqueue(r); } } public TaskChatResponse GetResponseAsync( IEnumerableChatMessage messages, ChatOptions? options null, CancellationToken cancellationToken default) { return Task.FromResult(_responses.Dequeue()); } }测试用例可以设计成第一次调用返回一个工具调用指令“查询订单状态”第二次调用返回最终答案。断言编排器在收到工具结果后确实走了“继续调用模型”的流程并且在第二次调用后正常收尾。这样测的是编排器的控制流而不是模型的推理能力稳定且快。这类测试建议至少在三个层面覆盖工具调用成功路径、工具调用失败路径、审批拒绝路径。审批拒绝是我发现最容易出 bug 的地方因为它跨了前端、Hub、任务队列三层缺一个环节就会卡死。最后再分享一点个人感受构建这套代理框架难点从来不在于“让模型干活”而在于给模型划定边界。ELO 自动权等级我建议第一版所有写操作都走人工确认跑上一个月观察用户真正信任哪些工具可以放开再逐步提权。不要被“全自动代理”的产品宣传牵着走企业场景里可解释、可中断、可审计比“聪明”更有价值。
返回列表