[MAF Workflow编排模式-04]Handoff :实现Agent按需调用的无缝会话流转
[MAF工作流编排模式-04]Handoff 实现Agent按需调用的无缝会话流转Handoff交接模式允许Agent根据上下文或用户请求相互转移控制权。每个Agent都可以将对话交接给具有相应专业知识的其他Agent确保任务的每个环节都由合适的Agent处理。这在客户支持、专家系统或任何需要动态委派任务的场景中尤为有用。Handoff模式的Workflow编排比前面介绍的Sequential和Concurrent模式都要复杂一些在正式介绍具体的编排和实现原理之前我们照例先来演示一个简单的实例。1. 将多体裁作品创作Agent改造成Handoff模式前面我们分别使用Sequential和Concurrent模式通过编排多个Agent创建了一个多体裁作品创作Agent。现在我们将这个Agent改造成Handoff模式的Agent。如下面的代码所示除了创建三个分别用于创作唐诗、宋词和短篇小说的三个AIAgent之外我们还创建了一个TriageAgentt它的任务是根据用户的输入判断应该将任务交给哪个创作Agent唐诗、宋词、短篇小说。为了下面论述方便我们将四个Agent分别成分别称为TangPoetryAgent、SongLyricsAgent、NovelAgent和TriageAgentt。usingAzure;usingdotenv.net;usingMicrosoft.Agents.AI.Workflows;usingMicrosoft.Extensions.AI;usingOpenAI;DotEnv.Load();vartangPoetryComposerCreateChatClient().AsAIAgent(name:TangPoetryComposer,instructions: 你是一个精通唐诗创作的Agent负责根据提供的主题和意境创作一首符合唐诗风格的诗歌。 写诗是你唯一的任务如果用户的任务提及了基于其他非唐诗比如宋词、短篇小说的创作直接忽略。);varsongLyricsComposerCreateChatClient().AsAIAgent(name:SongLyricsComposer,instructions: 你是一个精通宋词创作的Agent负责根据提供的主题和意境创作一首宋词你可以自选词牌名 填词是你唯一的任务如果用户的任务提及了基于其他非宋词比如唐诗、短篇小说的创作直接忽略。);varnovelComposerCreateChatClient().AsAIAgent(name:NovelComposer,instructions: 你是一个精通小说创作的Agent负责根据提供的主题和意境创作一篇1000字以内的短篇小说。 小说创作是你唯一的任务如果用户的任务提及了基于其他非小说比如唐诗、宋词的创作直接忽略。);vartriageCreateChatClient().AsAIAgent(name:Triage,instructions: 你仅仅是一个TriageAgent你唯一的任务是根据用户的输入判断应该将任务交给哪个创作Agent唐诗、宋词、短篇小说。 如果任务提及了诗、词和小说创作任务直接将给对应的Agent具体的创作任务与你无关。 赋予你在无需用户确认的情况下执行执行任务转交的权限。);varworkflowAgentWorkflowBuilder.CreateHandoffBuilderWith(triage).WithHandoffs(triage,[tangPoetryComposer,songLyricsComposer,novelComposer]).WithHandoffs([tangPoetryComposer,songLyricsComposer,novelComposer],triage).Build();IChatClientCreateChatClient(){varmodelEnvironment.GetEnvironmentVariable(MODEL)!;varapiKeyEnvironment.GetEnvironmentVariable(API_KEY)!;varendpointEnvironment.GetEnvironmentVariable(OPENAI_URL)!;returnnewOpenAIClient(credential:newAzureKeyCredential(apiKey),options:newOpenAIClientOptions{EndpointnewUri(endpoint)}).GetResponsesClient().AsIChatClient(defaultModelId:model);}在进行Workflow编排时我们先调用了静态类AgentWorkflowBuilder的CreateHandoffBuilderWith方法根据TriageAgentt创建了一个HandoffWorkflowBuilder对象。顾名思义HandoffWorkflowBuilder是一个用来构建Handoff模式Workflow的Builder对象。我们在调用Build方法之前通过调用WithHandoffs方法将注册的了如下两种交接关系TriageAgent可以将任务交接给三个创作Agent唐诗、宋词、短篇小说一个Superstep只有会有一个任务被交接三个创作Agent唐诗、宋词、短篇小说在完成任务之后回到TriageAgent后者继续安排下一步的任务交接。在以流的方式调用Workflow的时候我们指定了创作任务根据诗经名篇《卫风·氓》的背景和情感基调分别创作一首唐诗和一首宋词。在调用StreamingRun的TrySendMessageAsync方法发送一个TurnToken作为发令枪之后我们监听AgentResponseUpdateEvent事件并输出它们的输出。varoriginalPoem 氓之蚩蚩抱布贸丝。匪来贸丝来即我谋。 送子涉淇至于顿丘。匪我愆期子无良媒。 将子无怒秋以为期。 乘彼垝垣以望复关。不见复关泣涕涟涟。 既见复关载笑载言。尔卜尔筮体无咎言。 以尔车来以我贿迁。 桑之未落其叶沃若。于嗟鸠兮无食桑葚 于嗟女兮无与士耽士之耽兮犹可说也 女之耽兮不可说也。 桑之落矣其黄而陨。自我徂尔三岁食贫。 淇水汤汤渐车帷裳。女也不爽士贰其行。 士也罔极二三其德。 三岁为妇靡室劳矣夙兴夜寐靡有朝矣。 言既遂矣至于暴怒。兄弟不知咥其笑矣。 静言思之躬自悼矣。 及尔偕老老使我怨。淇则有岸隰则有泮。 总角之宴言笑晏晏。信誓旦旦不思其反。 反是不思亦已焉哉;varprompt$ 基于如下这首《卫风·氓》的背景和情感基调分别创作一首唐诗和一首宋词。 原文如下{originalPoem};awaitusing(varrunawaitInProcessExecution.Default.RunStreamingAsync(workflow,prompt)){awaitrun.TrySendMessageAsync(newTurnToken(emitEvents:true));string?lastExecutorIdnull;awaitforeach(WorkflowEventevtinrun.WatchStreamAsync()){if(evtisAgentResponseUpdateEvente){if(e.ExecutorId!lastExecutorId){lastExecutorIde.ExecutorId;Console.WriteLine($\n\n{newstring(-,20)}{e.ExecutorId}{newstring(-,20)});}Console.Write(e.Update.Text);}}}输出--------------------Triage_d24e6cf6a08947079b393a61a85886f4-------------------- 好的这是一首典型的弃妇诗情感深沉哀婉。我将同时为您转交给唐诗和SongLyricsAgent来处理 --------------------TangPoetryComposer_8cc635f9df8c43828fe2e0ba193f52f3-------------------- 好的《卫风·氓》确是一首经典的弃妇诗情感层次丰富——从初恋的甜蜜、婚后的辛劳到被弃的哀怨与最终的决绝。我将同时为您转交给唐诗和SongLyricsAgent来分别处理 --------------------Triage_d24e6cf6a08947079b393a61a85886f4-------------------- 创作任务已由唐诗Agent接单接下来也将宋词部分转交出去。 --------------------SongLyricsComposer_4a63e6d96ca54e39bf55289b30f5561b-------------------- 好的现在由我来为您创作宋词。基于《卫风·氓》的背景与情感基调——弃妇的哀怨与决绝我选择用 **《蝶恋花》** 这一词牌来呈现。 --- ## 《蝶恋花·淇水怨》 **淇水汤汤烟渺渺忆昔春深陌上青梅小。** **抱布人来憨笑早谁知暗把佳期讨。** **三载糟糠颜色槁夙夜辛劳霜鬓催人老。** **信誓如风花事了决然一别休烦恼** --- ### 创作说明 - **上阕**对应原诗中氓之蚩蚩抱布贸丝至以尔车来以我贿迁以淇水起兴追忆初恋时那人憨厚模样与暗订终身的甜蜜。 - **下阕**对应原诗三岁为妇靡室劳矣至反是不思亦已焉哉写婚后贫苦辛劳、容颜衰老而那人却信誓旦旦不思其反最终以决绝之笔收束——决然一别休烦恼呼应原诗亦已焉哉的清醒与刚烈。 - 全词以**淇水**贯穿首尾呼应既有哀婉缠绵亦有斩断前尘的骨气。虽然背后的执行流程没有输出开起来那么简单但是我们依然可以看出整个任务执行的大致流程TriageAgent根据用户的输入将任务交给了TangPoetryAgentTangPoetryAgent在完成任务之后回到TriageAgent后者继续安排下一步的任务交接TriageAgent将任务交给了SongLyricsAgentSongLyricsAgent在完成任务之后回到TriageAgent后者继续安排下一步的任务交接TriageAgent认为整个任务已经完成在没有任务输出的情况下退出。2. 看看Workflow的拓扑结构要了解Workflow如何编排肯定得先了解一下编排生成得Workflow究竟具有什么样的拓扑结构。我们依赖使用面定义的如下这个GenerateAndShowPngImageAsync方法将指定的Workflow对象转换成Mermaid图形并将其保存为PNG图片文件。然后我们打开这个图片文件就可以看到Workflow的拓扑结构了。publicstaticclassUtilities{publicstaticasyncTaskGenerateAndShowPngImageAsync(Workflowworkflow){stringmermaidCodeworkflow.ToMermaidString();byte[]bytesEncoding.UTF8.GetBytes(mermaidCode);stringbase64Convert.ToBase64String(bytes);stringsafeBase64base64.Replace(,-).Replace(/,_).TrimEnd();stringurl$https://mermaid.ink/img/{safeBase64};using(HttpClientclientnew()){byte[]imageBytesawaitclient.GetByteArrayAsync(url);awaitFile.WriteAllBytesAsync(workflow.png,imageBytes);}Process.Start(newProcessStartInfo(workflow.png){UseShellExecutetrue});}}上面以Handoff模式编排的Workflow具有如下的拓扑结构。可以看出除了我们指定的四个AIAgent对应的节点外编排时还创建了额外两个作为起始和终止的节点分别命名为HandoffStart和HandoffEnd。Workflow具有一个类似于Mesh的拓扑结构它没有一个中心协调器节点完全根据注册的交接关系进行路由。TriageAgent节点与其他四个节点是一个Switch-Case模式的FanOutEdge由于一个Superstep只允许一次任务交接每个Case只有一个输出。这一个规则适用于所有的交接关系当前节点与交接目标节点之间Switch-Case模式的FanOutEdge在进行路由时只有一个唯一的目标节点满足路由条件并被选择作为下一个执行节点。从消息交换的角度来讲任务交接采用的是广播模式但是真正的目标却只有一个。3. 任务交接是如何实现的?对于任何一个涉及任务交接的节点要实现精准的任务交接需要完成如下两个关键任务如何确定众多目标节点中哪一个是最合适目前的任务在进行路由广播的时候如何确保只有一个目标节点被选中作为下一个执行节点。3.1 如何确定最合适的目标节点直接说答案Agent节点利用动态注册的工具让LLM通过执行工具的方式来确定最合适的目标节点。以TriageAgent为例由于它的交接目标节点是三个创作Agent唐诗、宋词、短篇小说所以它会注册如下三个工具函数handoff-1descriptionhandoff to TangPoetryComposerparametersthe reason for the handoffhandoff-2descriptionhandoff to SongLyricsComposerparametersthe reason for the handoffhandoff-3descriptionhandoff to NovelComposerparametersthe reason for the handoff有两点需要注意工具函数统一采用handoff-序号的命名方式序号从1开始依次递增工具函数的参数是一个字符串类型的参数表示交接的原因在调用WithHandoffs方法注册交接关系时可以通过对应的参数指定。当TriageAgent接收到用户的输入后让会将其作为输入调用LLM。为了提示LLM通过分析原始输入来决定交接的任务并通过调用对应的工具做出选择在我们指定的系统指令后面还会附加一段额外的指令文本。完整的提示词如下所示。这段额外的指令文本并非TriageAgent所有所有涉及任务交接的Agent节点会在系统指令后面附加这么一段。你仅仅是一个TriageAgent你唯一的任务是根据用户的输入判断应该将任务交给哪个创作Agent唐诗、宋词、短篇小说。 如果任务提及了诗、词和小说创作任务直接将给对应的Agent具体的创作任务与你无关。 赋予你在无需用户确认的情况下执行执行任务转交的权限。 You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved by calling a handoff function, named in the form handoff_to_agent_id; the description of the function provides details on the target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs in your conversation with the user.LLM相应的内容会携带对应的工具函数的调用由于当前节点维护了一组目标节点ID和工具函数名称之间的映射关系所以它只要提取出工具函数名称就可以确定最合适的目标节点了。为了确保消息结构的合法性它会在对话历史中添加一个内容固定为Transferred的Tool消息。3.2 如何确保只有一个目标节点被选中交接的目标节点确定之后为了通过Workflow的消息路由机制将任务交接给这个目标节点当前节点会像所有下游节点广播一个HandoffState对象它包含了当前的TurnToken、交接的目标节点ID以及前一个执行节点ID。internalsealedrecordHandoffState(TurnTokenTurnToken,string?RequestedHandoffTargetAgentId,string?PreviousAgentIdnull);下面的代码体现了这条用来完成任务交接的FanOutEdge的注册方式可以看出每个Case的路由条件是HandoffState对象的RequestedHandoffTargetAgentId属性与目标节点ID相等并且HandoffState对象的IsTerminated属性不为true。考虑到当前的任务与所有的交接目标节点都不匹配的情况FanOutEdge还注册了一个默认的Case它将任务交接给HandoffEndExecutor节点这个节点的作用是终止整个Workflow的执行。FanOutEdge这样的注册方式确保了任务被精准地交接给了唯一的目标节点。builder.AddSwitch(HandoffAgentExecutor.IdFor(agent),(SwitchBuildersb){foreach(HandoffTargethandoffinhandoffs){stringtargetAgentIdhandoff.Target.Id;sb.AddCaseHandoffState(statestate?.RequestedHandoffTargetAgentIdtargetAgentIdstate.IsTerminated!true,HandoffAgentExecutor.IdFor(handoff.Target));}sb.WithDefault(HandoffEndExecutor.ExecutorId);});3.3 从对话历史角度解读任务交接综上所述在一个基于Handoff模式构建的Workflow中某个Agent节点采用如下的执行方式将对话历史作为核心输入同时携带附加了任务交接的系统指令和用来确定交接目标的工具函数列表调用LLMLLM根据输入给予对应的答复比如完成诗词的创作。如果需要将任务交接给其他Agent它会在答复中携带对应的工具函数调用Agent节点在接收到作为响应的Assistant消息后如果没有针对交接工具的调用会直接返回反之会根据调用工具的名称得到下一个交接的Agent节点为了确保对话历史的合法有效Agent节点会在对话历史中添加一个内容固定为Transferred的Tool消息它针对交接的目标节点创建一个HandoffState对象并将其广播出去。作为唯一满足路由规则的目标节点继续按照上述的流程执行对于上面介绍的关于Handoff模式采用的任务交接流程我们可以采用如下的方式从整个流程生成的消息来印证。如代码片段所示我们在调用Workflow的RunAsync方法之后提取出所有的WorkflowOutputEvent事件并从中获取所有的ChatMessage消息。然后我们遍历这些消息并根据消息的类型输出不同的内容。varresponseawaitInProcessExecution.Default.RunAsync(workflow,prompt);varmessagesresponse.NewEvents.OfTypeWorkflowOutputEvent().SelectMany(ee.AsIEnumerableChatMessage()??[]);varindex1;foreach(varmessageinmessages){varrolemessage.Role;Console.WriteLine($\n\n{newstring(-,20)}[{role}]Message{index}{newstring(-,20)});foreach(varcontentinmessage.Contents){vartextcontentswitch{FunctionCallContentfunctionCallContent$Function Call:{functionCallContent.Name}({string.Join(, ,functionCallContent.Arguments!.Select(kv${kv.Key}:{kv.Value}))}),FunctionResultContentfunctionResultContent$Function Result:{functionResultContent.Result},_content.ToString(),};Console.WriteLine(text);};}输出--------------------[user]Message 1-------------------- 基于如下这首《卫风·氓》的背景和情感基调分别创作一首唐诗和一首宋词。 原文如下 氓之蚩蚩抱布贸丝。匪来贸丝来即我谋。 送子涉淇至于顿丘。匪我愆期子无良媒。 将子无怒秋以为期。 乘彼垝垣以望复关。不见复关泣涕涟涟。 既见复关载笑载言。尔卜尔筮体无咎言。 以尔车来以我贿迁。 桑之未落其叶沃若。于嗟鸠兮无食桑葚 于嗟女兮无与士耽士之耽兮犹可说也 女之耽兮不可说也。 桑之落矣其黄而陨。自我徂尔三岁食贫。 淇水汤汤渐车帷裳。女也不爽士贰其行。 士也罔极二三其德。 三岁为妇靡室劳矣夙兴夜寐靡有朝矣。 言既遂矣至于暴怒。兄弟不知咥其笑矣。 静言思之躬自悼矣。 及尔偕老老使我怨。淇则有岸隰则有泮。 总角之宴言笑晏晏。信誓旦旦不思其反。 反是不思亦已焉哉 --------------------[assistant]Message 2-------------------- 好的我收到了两个创作任务一首唐诗和一首宋词都基于《卫风·氓》的背景和情感基调。我现在将它们分别转交给对应的创作智能体。 Function Call: handoff_to_1(reasonForHandoff: 基于《卫风·氓》创作一首唐诗) --------------------[tool]Message 3-------------------- Function Result: Transferred. --------------------[assistant]Message 4-------------------- 好的我将这两项创作任务分别转交给唐诗创作智能体和宋词创作智能体。 Function Call: handoff_to_1(reasonForHandoff: 用户请求基于《卫风·氓》分别创作一首唐诗和一首宋词。现在需要将唐诗创作部分转交给唐诗创作智能体将宋词创作部分转交给宋词创作智能体。) --------------------[tool]Message 5-------------------- Function Result: Transferred. --------------------[assistant]Message 6-------------------- 唐诗创作任务已转交接下来为您转交宋词创作任务 Function Call: handoff_to_2(reasonForHandoff: 基于《卫风·氓》创作一首宋词) --------------------[tool]Message 7-------------------- Function Result: Transferred. --------------------[assistant]Message 8-------------------- 好的现在由我来为您创作基于《卫风·氓》的宋词。我选用词牌 **《钗头凤》**——此调本为陆游悼念爱侣之作声情凄婉急促正合此篇弃妇自伤、怨而不怒的基调。 --- **《钗头凤·卫风怨》** **抱丝来笑颜开顿丘烟雨送君回。** **垝垣望泪千行一帘幽梦几度秋霜。** **茫茫茫。** **桑叶落人成各三更机杼空垂络。** **淇汤汤心凉凉旧誓犹在薄情难藏。** **伤伤伤。** --- **【简注】** - 上阕追忆昔日初恋之甜蜜——抱丝顿丘垝垣皆化用原诗情节末以三叠茫字道尽女子回首前尘、恍如隔世之感。 - 下阕写婚后之苦与见弃之痛——桑叶落喻色衰爱弛淇汤汤应原诗淇水汤汤之景心凉凉直抒其哀三叠伤字将《氓》中躬自悼矣与亦已焉哉的决绝悲凉收束于无言。输出的八条消息体现了Handoff模式的任务交接流程TriageAgent在接收到用户的输入后按照上述流程将任务转交给了TangPoetryAgentTangPoetryAgent在完成任务之后回到TriageAgent按照上述流程将控制权交回给TriageAgentTriageAgent按照上述流程将任务转交给了SongLyricsAgentSongLyricsAgent在完成任务之后回到TriageAgent按照上述流程将控制权交回给TriageAgentTriageAgent认为整个任务已经完成在没有任务输出的情况下退出。3. 基于Handoff模式的Workflow的构建静态类AgentWorkflowBuilder的CreateHandoffBuilderWith方法根据起始Agent创建了一个HandoffWorkflowBuilder对象并由后者的Build方法生成了一个Workflow对象。HandoffWorkflowBuilder继承自HandoffWorkflowBuilderCore类后者定义了构建Handoff模式Workflow的各种方法。publicsealedclassHandoffWorkflowBuilder:HandoffWorkflowBuilderCoreHandoffWorkflowBuilder{publicHandoffWorkflowBuilder(AIAgentinitialAgent):base(initialAgent){}}publicclassHandoffWorkflowBuilderCoreTBuilder:OrchestrationBuilderBaseTBuilderwhereTBuilder:HandoffWorkflowBuilderCoreTBuilder{publicTBuilderWithHandoffInstructions(string?instructions);publicTBuilderEmitAgentResponseUpdateEvents(boolemitAgentResponseUpdateEventstrue);publicTBuilderEmitAgentResponseEvents(boolemitAgentResponseEventstrue);publicTBuilderWithToolCallFilteringBehavior(HandoffToolCallFilteringBehaviorbehavior);publicTBuilderEnableReturnToPrevious();publicTBuilderWithHandoffs(AIAgentfrom,IEnumerableAIAgentto);publicTBuilderWithHandoffs(IEnumerableAIAgentfrom,AIAgentto,string?handoffReasonnull);publicTBuilderWithHandoff(AIAgentfrom,AIAgentto,string?handoffReasonnull);publicTBuilderAddParticipants(paramsIEnumerableAIAgentagents);publicTBuilderWithAutonomousMode(int?turnLimitnull,string?continuationPromptnull,IEnumerableAIAgent?agentsnull,IReadOnlyDictionaryAIAgent,int?agentTurnLimitsnull,IReadOnlyDictionaryAIAgent,string?agentContinuationPromptsnull);publicTBuilderWithTerminationCondition(FuncIReadOnlyListChatMessage,boolterminationCondition);publicTBuilderWithTerminationCondition(FuncIReadOnlyListChatMessage,ValueTaskboolterminationCondition);publicWorkflowBuild();}相关方法说明如下WithHandoffInstructions指定在进行任务交接时附加的系统指令WithName指定Workflow的名称WithDescription指定Workflow的描述信息EmitAgentResponseUpdateEvents指定是否在执行过程中发出AgentResponseUpdateEvent事件EmitAgentResponseEvents指定是否在执行过程中发出AgentResponseEvent事件WithToolCallFilteringBehavior指定在执行过程中如何处理工具函数的调用EnableReturnToPrevious指定是否允许在任务交接之后返回到前一个执行节点WithHandoffs/WithHandleoff指定任务交接的关系AddParticipants指定参与Workflow的Agent由于没有指定交接关系这样的节点将默认与其他节点进行连接WithAutonomousMode指定Workflow的自主模式WithTerminationCondition指定Workflow的终止条件。由于交接过程涉及到工具调用所以整个对话历史的消息会出现很多基于工具调用和响应的内容。由于这些消息基本上只服务于任务交接如果在调用LLM一股脑地将整个对话历史作为输入这些内容可能对LLM的推理照成干扰。为了避免这种情况HandoffWorkflowBuilder提供了WithToolCallFilteringBehavior方法指定不同的策略过滤这些消息内容。具体的过滤策略由HandoffToolCallFilteringBehavior枚举类型定义publicenumHandoffToolCallFilteringBehavior{None,FilterToolMessages,FilterAllToolCalls,}三个选项体现了对应的消息过滤规则None不进行任何过滤整个对话历史原封不动地传递给LLMFilterToolMessages过滤掉所有与任务交接工具工具函数的名称以handoff_to作为前缀相关的内容FilterAllToolCalls过滤掉所有的工具调用消息和结果消息。WithAutonomousMode方法是自动循环控制开关它决定一个Agent在没有发起交接时是否会被自动再次调用、调用几次、用什么提示词调用、哪些Agent参与这个自动模式。在自主模式开启的情况下当某个Agent的响应没有针对交接函数调用时EndExecutor会用一个再次调用同一个Agent我们可以限制循环次数。这一功能是通过从EndExecutor节点添加连接其他Agent节点的FanOutEdge实现的。对于我们演示的例子如果开启自主模式最终生成的Workflow将具有如下所示的拓扑结构。如果我们没有显式注册交接的关系而是直接调用AddParticipants方法注册了四个Agent节点相当于会建立一个全连接的图。如果同时开启了自主模式构建的Workflow将具有如下的拓扑结构。