由于Sequential模式构建的Worflow由多一个AIAgent按照编排的顺序执行,前置节点越权执行是一个非常常见的现象。举个简单的例子,假设我们采用此模式构建一个包含如下三个Agent的Workflow:数据收集Agent、数据分析Agent和数据报告Agent,在调用时Workflow时我输入如下的任务:“提取最近三年手机三大品牌全球销量、然后通过分析数据生成一份销售报告,最终以邮件形式发出来”。貌似很合理对吧,但是会出现一个问题:完整的提示词的三部分其实是分别对三个Agent说的,但是完整的提示词直接传给了第一个Agent,导致第一个Agent越权执行了后续两个Agent的任务。虽然可以利用系统指令强行让每个Agent只关注自己负责的那一块,但是这样的做法并非每次都有效。
1. 前置节点把所有的事都干了
还记得前文演示的多体裁作品创作智能体的三个分别用于创作唐诗、宋词和短篇小说的三个AIAgent是如何创建的吗?如下面的代码所示,我们通过指定了系统指令让三个Agent各司其职,不要越界。
var tangPoetryComposer = CreateChatClient()
.AsAIAgent(
name: "TangPoetryComposer",
instructions: """
你是一个精通唐诗创作的智能体,负责根据提供的主题和意境创作一首符合唐诗风格的诗歌。
如果用户的任务了提及了基于其他非唐诗(比如宋词、短篇小说)的创作,请忽略。
""");
var songLyricsComposer = CreateChatClient()
.AsAIAgent(
name: "SongLyricsComposer",
instructions: """
你是一个精通宋词创作的智能体,负责根据提供的主题和意境创作一首宋词,你可以自选词牌名
如果用户的任务了提及了基于其他非宋词(比如唐诗、短篇小说)的创作,请忽略。
""");
var novelComposer = CreateChatClient()
.AsAIAgent(
name: "NovelComposer",
instructions: """
你是一个精通小说创作的智能体,负责根据提供的主题和意境创作一篇1000字以内的短篇小说。
如果用户的任务了提及了基于其他非小说(比如唐诗、宋词)的创作,请忽略。
""");
如果将对应的排他性指令文本去掉(第二句话)去掉后再次执行演示程序,就会出现如下的输出。从输出可以看出用于创建唐诗和小说的Agent都完成了三种体裁的创作。由于LLM的生成是基于概率的,所以每次执行的结果可能不一样,但是前置节点越权执行的现象是必然会出现的。而且及时我们添加了排他性指令,其实也不能保证每次执行都能有效。
--------------------TangPoetryComposer_1cb0e91144de43c7bbf145a3e0a0cd73--------------------
《弃妇吟》
淇水汤汤送旧人,桑叶飘零碾作尘。
昔时抱布情如蜜,今日挥鞭意似焚。
三秋甘受贫家苦,一夜翻成陌路嗔。
莫道红颜多薄命,从来白首负深恩。
《蝶恋花·秋桑叹》
犹记抱布春意逗,涉过淇滨,暗把终身就。桑葚垂枝红染袖,谁知鸠鸟啄心透。
黄叶西风凋碧后,贫贱夫妻,泪渍青衫皱。信誓如烟逐水流,残阳空照秋桑瘦。
《氓之变》
秋雨敲打着淇水河岸,阿蘅跪在泥泞里捡拾散落的蚕茧。三年前那个抱着布匹来换丝的男人,此刻正把她的妆奁往牛车上扔,镶铜的奁盒砸在青石上发出碎响。
“这些桑叶全喂了野蚕。”氓踢开竹匾,去年晒好的桑叶卷着霉斑滚进水洼。他腰间别着新换的玉钩,那是城西当铺李掌柜家闺女的陪嫁。阿蘅还记得三个月前,氓第一次穿上绸衫时,袖口掉出的鸳鸯锦帕。
淇水的浪头打湿了她的麻布裙裾,恍惚间变成成亲那日被抱上牛车时溅起的河水。那时氓的掌心贴着喜饼的油渍,许下的誓比顿丘的磐石还重。可三更天的织机声磨破了她的指尖,灶台的烟火熏哑了她的嗓子,却换来氓把算盘摔在她膝上:“三年就织了这些?”
最寒的不是秋风,是今早氓把休书拍在桌上的声响。泛黄的纸上列着她的罪状:无子、多言、炊米太费。她忽然想起母亲当年用桑葚汁给她染指甲时说:“斑鸠吃多了桑葚会醉死,女子贪恋情爱会碎心。”
牛车启动时,阿蘅攥住车辕:“你说过白头偕老的。”氓的鞭子抽在黄牛背上,声音比初冬的冰还脆:“淇水还有岸,隰地还有边,就你这怨气没个尽头。”
散落的蚕茧被车轮碾成絮,阿蘅突然笑了。她想起总角时和氓偷摘邻家桑葚,紫红的汁液染了满襟,那时他说要娶她,她笑得像三月的黄莺。如今才懂,斑鸠醉倒尚能醒,女子醉情终生误。
--------------------SongLyricsComposer_bde281621a5a408dbaee85b9f1e99640--------------------
《雨霖铃·秋桑叹》
秋霖初歇。对淇河晚,败叶堆雪。
残垣尚记凝望,烟波断处,归帆明灭。
总角青梅如昨,竟翻作长别。
算三载、机杼声寒,茧破丝空寸心裂。
斑鸠误啄桑葚血。更那堪、露重芳菲歇。
罗襦暗数针脚,针针是、旧年呜咽。
玉勒新鞍,谁见空奁碎珮,堕寒月。
纵觅得、隰泮淇涯,不是回春碣。
注:此词以《雨霖铃》为调,取秋雨初霁、桑林凋敝之象,暗合弃妇零落身世。上阕以“残垣凝望”勾连《氓》诗“乘彼垝垣”之境,“茧破丝空”喻三年辛劳成空。下阕化用“鸠食桑葚”典故,以“玉勒新鞍”对照“空奁碎珮”,凸显男子变心后的物质反差。结句借“隰泮淇涯”反用原诗,言纵有边界可寻,而人生无再少之春,较原诗“亦已焉哉”更添一层宿命悲怆。
--------------------NovelComposer_5aa12d37851d4a3194ed6c97b6cf7cbb--------------------
好的,依你之愿,我将为你呈现基于同一母题、却风格迥异的三篇创作。我们将先从唐诗的凝练哀婉,到宋词的深婉悱恻,最后步入短篇小说的幽微内心,层层递进,将《氓》中的千古悲歌娓娓道来。
---
### **唐诗·淇水别**
淇水汤汤送旧人,桑之落矣碾为尘。
垝垣曾望关山远,车马今来涕泪新。
三岁为劳甘食苦,一朝反目忍言嗔。
信誓旦旦终成幻,独向西风悼此身。
---
### **宋词·蝶恋花·秋桑叹**
犹记抱布春意逗,涉过淇滨,暗把终身就。桑叶沃若盈翠袖,谁知鸠鸟啄心透。
黄叶西风凋碧后,贫贱夫妻,泪渍青衫皱。信誓如烟逐水流,残阳空照秋桑瘦。
---
### **短篇小说·桑落**
阿蘅最后看一眼那个家时,院角的桑树正落下最后一片叶子。
叶子飘得很慢,在空中打了个旋,才不甘不愿地贴在湿漉漉的泥地上。她想起三年前,就是在这棵桑树下,氓用粗粝的手掌包着她的手,说要用最好的布来换她最好的丝。那时他的眼睛亮得像淇水上的星光,让她忘了母亲说过的话——斑鸠吃多了桑葚会醉,女子沉溺于情爱会碎心。
醉的是她。碎了的,也是她。
三更的织机声还响在耳畔。梭子来回穿梭,像永远走不到头的日子。她的手指被丝线勒出一道道血口,愈合,又裂开,最后结成层层叠叠的茧。氓说她织的布不够细密,说她煮的粥太稀,说她回娘家的次数太多。他的声音起初是失望,后来是不耐,最后变成了冰。
今早他把休书拍在桌上时,阿蘅正端着粥。粥碗晃了晃,几粒米溅在休书上,洇开了“无子”两个字。她盯着那两个字看了很久,久到氓的呵斥声都变成了嗡嗡的背景音。她只是在想,这三年里,哪个夜晚不是为了织布熬到天亮?哪顿饭不是先紧着他吃?哪句话不是小心着说,怕惹他皱眉?
可这些都不在休书上。
休书上写的是:无子,多言,炊米太费。
阿蘅突然想笑。她想问氓,你腰间那枚新换的玉钩,是用哪一匹布换的?你袖口掉出的那方鸳鸯锦帕,又是哪家姑娘的针脚?但她最终什么也没问,只是弯腰捡起粥碗的碎片。碎片扎进掌心,疼得清醒。
此刻她站在淇水边,看着氓把她的妆奁扔上牛车。奁盒砸在青石上,她母亲留下的那面铜镜滚了出来。她弯腰去捡,手指刚触到镜面,氓的鞭子抽在牛背上,车轮碾过铜镜,碾过她最后一寸念想。
“淇水还有岸,隰地还有边。”
氓的声音从车上飘下来,被秋风吹散。阿蘅攥着被碾出裂纹的铜镜,镜中映着她的脸——二十岁的面容,却像桑树落尽叶子的枝桠。
她忽然想起从前偷摘邻家桑葚的光景。氓爬树,她在树下兜着衣襟接。紫红的浆果噼里啪啦落下来,染得她满襟都是。氓从树上跳下来,摘了颗最饱满的塞进她嘴里,甜得她眯起眼睛笑。那时他说要娶她,她说好。两个总角小儿,把过家家当成了真的。
她竟忘了,过家家是可以散的。
桑葚的甜是醉人的。斑鸠醉倒了,尚能醒来。而她醉倒了,醒来时已是满目荒芜。
阿蘅抬起头,最后看了一眼远去的牛车。淇水汤汤向东流,岸边的芦苇在秋风里摇成一片苍黄。她把铜镜放进怀里,往娘家的方向走去。
走了七步,停下。
又走了七步,再停下。
不是因为留恋——只是因为风冷,露重,而她还没来得及学会,如何做一个不再回头的女人。
2. 通过提示词隔离的方式彻底解决前置节点越权执行
前置节点越权的问题根源在于让Agent看到了超越其任务范围的提示词信息。虽然系统指令具有最强的约束能力,但是具体听谁的还取决于具体的语言组织以及LLM自身的理解,说白了这是两种力量之间的对抗,谁胜谁负其实很难控制。要彻底就解决这个问题,唯有提示词隔离一种办法,也就是让每个Agent在执行时只能看到自己任务范围内的提示词信息,而看不到其他Agent的提示词信息。为此我重新定义了基于Sequential模式编排Workflow的BuildSequential方法。
static Workflow BuildSequential(params (AIAgent Agent, List<ChatMessage>? Messages)[] agentsWithInjectedMessages)
如上面的代码所示,我将原来的BuildSequential方法的参数类型从AIAgent列表改成了(AIAgent Agent, List<ChatMessage>? Messages)元组列表,也就是说可以我们在每个Agent执行后为后续Agent注入一组提示词信息。我们使用此方法重写了前面的演示程序:三个Agent的指令只关注自己负责的那一块,并且不再添加排他性的指令文本。在调用BuildSequential方法时,我们将针对宋词的创作任务注入到唐诗的Agent执行后,针对短篇小说的创作任务注入到宋词的Agent执行后。原始的提示词只提到了唐诗的创作任务。
using Azure;
using dotenv.net;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using OpenAI;
DotEnv.Load();
var tangPoetryComposer = CreateChatClient()
.AsAIAgent(
name: "TangPoetryComposer",
instructions: """
你是一个精通唐诗创作的智能体,负责根据提供的主题和意境创作一首符合唐诗风格的诗歌。
""");
var songLyricsComposer = CreateChatClient()
.AsAIAgent(
name: "SongLyricsComposer",
instructions: """
你是一个精通宋词创作的智能体,负责根据提供的主题和意境创作一首宋词,你可以自选词牌名
""");
var novelComposer = CreateChatClient()
.AsAIAgent(
name: "NovelComposer",
instructions: """
你是一个精通小说创作的智能体,负责根据提供的主题和意境创作一篇1000字以内的短篇小说。
""");
var workflow = BuildSequential(
(tangPoetryComposer, [new ChatMessage(ChatRole.User, "根据提供的诗歌《卫风·氓》的背景和情感基调创作一首宋词")]),
(songLyricsComposer, [new ChatMessage(ChatRole.User, "根据提供的诗歌《卫风·氓》的背景和情感基调创作一篇短篇小说")]),
(novelComposer, null));
var originalPoem = """
氓之蚩蚩,抱布贸丝。匪来贸丝,来即我谋。
送子涉淇,至于顿丘。匪我愆期,子无良媒。
将子无怒,秋以为期。
乘彼垝垣,以望复关。不见复关,泣涕涟涟。
既见复关,载笑载言。尔卜尔筮,体无咎言。
以尔车来,以我贿迁。
桑之未落,其叶沃若。于嗟鸠兮,无食桑葚!
于嗟女兮,无与士耽!士之耽兮,犹可说也;
女之耽兮,不可说也。
桑之落矣,其黄而陨。自我徂尔,三岁食贫。
淇水汤汤,渐车帷裳。女也不爽,士贰其行。
士也罔极,二三其德。
三岁为妇,靡室劳矣;夙兴夜寐,靡有朝矣。
言既遂矣,至于暴怒。兄弟不知,咥其笑矣。
静言思之,躬自悼矣。
及尔偕老,老使我怨。淇则有岸,隰则有泮。
总角之宴,言笑晏晏。信誓旦旦,不思其反。
反是不思,亦已焉哉!
""";
var prompt = $"""
基于如下这首《卫风·氓》的背景和情感基调创作一首唐诗。
原文如下:
{originalPoem}
""";
await using (var run = await InProcessExecution.Default.RunStreamingAsync(workflow, prompt))
{
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
string? lastExecutorId = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is AgentResponseUpdateEvent e)
{
if (e.ExecutorId != lastExecutorId)
{
lastExecutorId = e.ExecutorId;
Console.WriteLine($"\n{new string('-', 20)}{e.ExecutorId}{new string('-', 20)}");
}
Console.Write(e.Update.Text);
}
}
}
IChatClient CreateChatClient()
{
var model = Environment.GetEnvironmentVariable("MODEL")!;
var apiKey = Environment.GetEnvironmentVariable("API_KEY")!;
var endpoint = Environment.GetEnvironmentVariable("OPENAI_URL")!;
return new OpenAIClient(
credential: new AzureKeyCredential(apiKey),
options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetResponsesClient()
.AsIChatClient(defaultModelId: model);
}
输出:
--------------------TangPoetryComposer_5744b9b6cc4647e19ac4af864b062572--------------------
《代卫风弃妇吟》
淇水汤汤送旧人,复关望断几回春。
盟言犹在耳,秋以为期信誓真。
三年夙夜侍蚕桑,罗帷未暖君心变。
鸠食桑葚不知醉,妾悔当初错认君。
--------------------SongLyricsComposer_779e58349c054b0ca3db128721799c68--------------------
《钗头凤·淇水寒》
淇水咽,秋期绝,复关残月音书灭。
蚕桑歇,罗帷裂,三年夙夜,一朝霜雪。
切!切!切!
鸠贪蜜,桑空碧,总角欢愉成追忆。
盟如屑,郎心铁,岸泮犹在,信誓虚设。
决!决!决!
注:此词以《卫风·氓》弃妇之怨为骨,取陆游唐婉词牌之体。上片以“淇水咽”起兴,呼应原诗“送子涉淇”之景,“秋期绝”暗藏“秋以为期”之誓成空。下片“鸠贪蜜”反用“无食桑葚”之诫,结句三重“决”字,既合原诗“亦已焉哉”之决绝,更添词体特有的顿挫呜咽之致,较之陆游唐婉的和词,另具《诗经》古拙风韵。
--------------------NovelComposer_01d47dcfc1e142a59136a6cbd3a6cd06--------------------
淇水边的桑树又落叶了。
我站在岸边,看着河水汤汤,恍惚间又看见当年那个抱着布匹的男子。他笑得憨厚,说是来买丝,眼睛却一直瞟着我。那时我以为这是命中注定的姻缘,傻傻地送他到顿丘,又傻傻地等了一个秋天。
复关的城墙我都爬了多少回了。每回望不见他的身影,就躲在垝垣后面掉眼泪。姐妹们笑话我痴,我只当她们不懂。后来他来了,带着占卜的吉兆,我高兴得什么似的,催着他赶快把车赶来,把我的嫁妆都搬走。
新婚那夜,他叫我“小桑叶”。他说我的脸颊像春天的桑叶一样嫩。我羞得直往他怀里钻,觉得自己是天底下最幸运的女子。
三年。整整三年。
鸡叫头遍我就起身,纺线织布到月上中天。他的手越来越懒,脾气却越来越大。我做的饭嫌淡,缝的衣裳嫌粗,就连走路的声音都嫌吵。有一次他喝了酒回来,抄起棍子就打,说我在外头勾搭别的男人。
我去找兄弟诉苦。哥哥们正在院子里喝酒,见我哭哭啼啼进来,先是一愣,继而哈哈大笑。
“早就叫你别嫁那个穷光蛋,你偏不听。”
“如今知道回来哭了?”
“嫁出去的女儿泼出去的水,你找我们有什么用?”
我站在堂屋中间,像一只落汤鸡。他们继续喝酒划拳,笑声震得屋顶的灰都往下掉。我退出来的时候,连门槛都差点绊我一跤。
那天夜里我又来到淇水边。河水还是那样流着,不急不缓,就像三年前我等他娶我的时候一样。可我知道,河水有岸,沼泽有边,我这份苦处却没个尽头。
他曾经说过要跟我白头到老的。那时候我们在河边捉蟋蟀,在桑林里采桑葚,他指天发誓说这辈子只对我一个人好。我当时信了,信得真真的,就像斑鸠贪吃桑葚一样,吃得醉醺醺,完全不知道醉过后是什么下场。
现在我知道了。
女人一旦陷进去,就再也出不来。男人说变就变,今天说爱你,明天就能把你当仇人。可女人不行,女人把自己整个儿都投进去了,等发现所托非人时,连魂都找不回来了。
河水还是汤汤地流。
我站起来,拍了拍裙上的土。既然誓言都成了空,那就算了吧。桑叶落了就落了,还能怎么着。
我最后看了一眼这条淇水。河面上飘着一片枯黄的桑叶,打着旋儿,慢慢沉了下去。
就像我这三年,就这么沉下去了。
回头的时候,风吹过来,冷得很。我把袖子拢了拢,往娘家方向走。路还是那条路,只是这世上再也没有“小桑叶”了。
有人可能会说,你这也没有完全隔离呀,写宋词的Agent还是看到了唐诗的创作任务呀,写小说的Agent还能同时看到了唐诗和宋词的创作任务呀。但是这反而是对的,因为Sequential模式下就是需要让后续节点在前序节点的成果基础上继续执行任务,虽然它看到了不属于自己任务范围的提示词信息,但是它也看到前序节点已经完成了各自的任务,加上系统指令的约束,它知道该做什么。
3. Sequential模式的消息注入
重写的BuildSequential方法本质上就是在每个AIAgent节点之后添加一个额外的节点实现了消息注入的功能。对于上面构建的Workflow,它具有如下的结构:
3.1 MessageInjectingExecutor
Workflow中用来注入消息的Executor为如下这个MessageInjectingExecutor。它继承自ChatProtocolExecutor,所以可以成为采用Chat协议的数据流的一个标准的环节。它注入的消息通过构造函数提供的List<ChatMessage>对象来指定,并在重写的TakeTurnAsync方法中将累积和注入的消息一起发送出去。
public sealed class MessageInjectingExecutor(List<ChatMessage>? additionalMessages)
: ChatProtocolExecutor("MessageInjecting"+Guid.NewGuid())
{
protected override async ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
{
if (messages?.Any() ?? false)
{
await context.SendMessageAsync(messages, cancellationToken).ConfigureAwait(false);
}
if (additionalMessages?.Any() ?? false)
{
await context.SendMessageAsync(additionalMessages, cancellationToken).ConfigureAwait(false);
}
}
}
3.2 OutputMessagesExecutor
由于用来输出ChatMessage列表的OutputMessagesExecutor时一个internal类型,所以我们不得不重新定义,完整的代码如下所示:
internal sealed class OutputMessagesExecutor : ChatProtocolExecutor, IResettableExecutor
{
public OutputMessagesExecutor(ChatProtocolExecutorOptions? options = null)
: base("OutputMessages", options, declareCrossRunShareable: true)
{ }
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
protected override ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
=> context.YieldOutputAsync(messages, cancellationToken);
}
3.3 BuildSequential方法
如下所示的是我们自定义的BuildSequential方法的完整代码。我们在调用此方法的时候需要指定一组(AIAgent Agent, List<ChatMessage>? Messages)元组列表。我们利用提供的AIAgent和随后注入的消息列表分别创建出对应的AgentHostExecutor和MessageInjectingExecutor,并将它们按照顺序连接起来。最后我们在Workflow的末尾添加一个OutputMessagesExecutor来输出最终的消息列表。
static Workflow BuildSequential(params (AIAgent Agent, List<ChatMessage>? Messages)[] agentsWithInjectedMessages)
{
var options = new AIAgentHostOptions
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true
};
List<ExecutorBinding> agentExecutors = agentsWithInjectedMessages
.Select(agent => agent.Agent.BindAsExecutor(options))
.ToList();
List<ExecutorBinding> messageInjectingExcutors = agentsWithInjectedMessages
.Select(agent => (ExecutorBinding)new MessageInjectingExecutor( agent.Messages))
.ToList();
WorkflowBuilder workflowBuilder = new WorkflowBuilder(agentExecutors[0]);
for (var index = 0; index < agentExecutors.Count; index++)
{
workflowBuilder.AddEdge(agentExecutors[index], messageInjectingExcutors[index]);
if (index < agentExecutors.Count - 1)
{
workflowBuilder.AddEdge(messageInjectingExcutors[index], agentExecutors[index + 1]);
}
}
var outputMessagesExecutor = new OutputMessagesExecutor();
workflowBuilder
.AddEdge(messageInjectingExcutors.Last(), outputMessagesExecutor)
.WithOutputFrom(outputMessagesExecutor);
return workflowBuilder.Build();
}


被折叠的 条评论
为什么被折叠?



