前言
上篇文章我们大致演示了一下ai_agent
的食用方法。这里我们做一下核心模块runtime
的设计和实现。
一个agent也好,workflow也好,他们单个实现起来并不复杂,困难的是如何将他们有机的组合起来,能够按照一定的逻辑流转起来。并且能够层层嵌套,能力无限。
理解起来比较抽象,我们先看几个重点方向,和现实中的例子:
1. 简单的workflow场景
我们可以使用一个简单的workflow来处理一些场景,比如奶茶店的智能推荐,那么它的一个流程大致如下:
- 这个流程就能看到,我们将llm,memory,prompt都当做一个节点,按照图中的顺序执行最终会得到一个推荐结果。
- 可以说这是一个workflow的基础功能。
2. smartflow场景
对于有些问题,我们是不能够将完全预测出执行流程,比如Least-to-Most
和smartflow
的场景,这些节点往往是在执行过程中慢慢生成出来的。
我们还是举个例子, 假设有这样一个agent:评阅大师,它的目标是评阅优化输入文案。那么它的工作流程可能是这样的。
- 工作方式一目了然,可以理解为一个评分llm,一个优化llm。对于一个文案,评分llm不断给出需要优化的点,并追加到上下文中,优化llm则根据要求不断优化,直到评分>80。
- 我们的流程设计肯定不能是DAG,因为出现环了。
3. multi-agent场景
multi-agent
常用来解决复杂问题,对流程设计的要求也更高。比较典型的场景是狼人杀。它的流程可能长这样。
- 上图画的游戏规则并不严谨,但大体能感受到一个
multi-agent
的工作方式。这里面的每个角色都可以理解为一个agent,主持人可以是一个固定的脚本。 - 这个场景要求每个agent都有自己的prompt,有独立的memory等,将一个agent放大,那么这个agent也应该是一个worlflow,由无数的节点拼接而来。也就要求我们流程设计能够层层嵌套,能力无限。
4. NL2code场景
保持开放是一个非常重要的能力,一来是能够让程序员直接写脚本。二是大模型有时候也会自己写脚本,也就是NL2Code
场景。
我之前参与过一个专项,其中的一个能力是用户自由操作文档,比如文档归类,概要摘取等,结果通过text2sql
存到数据库中。
这种场景下,仅仅提供有限功能的节点是不足够的。必须具备无限扩展的能力。
5. 开放域
在开放域中,agent将具备更自由的意志。举几个典型的场景:聊天室
游戏NPC
虚拟宠物
在这种应用中,agent更应该是一个完备的ai,具备更长的生命周期,更强的主观能动性。
agent从运行到结束,要像人一样,生下来就有意识,直到死亡。
简单的做法是先构建一个single agent,并让它至少有个memory+tool+设定这几个模块,最好是采用多模态大模型做基座模型。然后不断循环调用这个agent,从而达到和我一样的牛马状态。当然成本肯定极高。
另一种方法是做双循环,还是这个agent,一个循环是外界有输入后再调用agent,另一个循环是定时唤起agent的主观意识。从而节能减排。
不管是哪种方式,我更倾向于从agent外部解决。而非agent本身。
总结
现实中应用肯定不局限于这几种情况,但通过一定的流程编辑基本都可以解决,只是复杂性会比较高。
runtime 设计
一个agent的runtime大概可以看成是由这几部分构成:调度+节点服务+执行计划+上下文。
节点 service
一个service可以理解成一个原子能力,比如llm,比如tool,也可能是function。
我们这里做一个抽象:
rust
pub trait Service: Send + Sync {
async fn call(&self, flow: Flow) -> anyhow::Result<Output>;
}
执行计划
这个也很好理解,我们需要按照这个计划不断执行service。
rust
pub trait Plan: Send + Sync {
fn next(&self, ctx: Arc<Context>, node_id: &str) -> NextNodeResult;
fn set(&self, nodes: Vec<PlanNode>);
fn update(
&self,
node_code: &str,
update: Box<dyn FnOnce(Option<&mut PlanNode>) -> anyhow::Result<()>>,
) -> anyhow::Result<()>;
}
上下文Context
这个所谓的上下文Context
可以理解为 执行一个任务的具体消息,比如任务编码,堆栈信息,状态等。
- 这里有一个子上下文的概念,也就是说在一个
service
里面可以执行一个子计划,也就是能力无限的实现。并且父子上下文会共享堆栈。todo:也会共享状态。 - 这里没有做共享设计,每个
Context
会记录自己归属的运行时。也就是每个运行时都是独立的,为了多租户设计的。
rust
pub struct Context {
pub parent_code: Option<String>,
//任务流名称
pub code: String,
//状态
pub status: AtomicU8, //0:init 1:running, 2:success, 3:error
//堆栈信息
pub stack: Arc<Mutex<ContextStack>>,
//执行计划
pub plan: Arc<dyn Plan>,
//全局扩展字段
pub extend: Mutex<HashMap<String, Box<dyn Any + Send + Sync + 'static>>>,
//结束时回调
pub over_callback: Option<Mutex<Vec<Box<dyn FnOnce(Arc<Context>) + Send + Sync + 'static>>>>,
pub(crate) runtime: Arc<Runtime>,
}
调度
调度也很简单,就是按照plan
不断执行service
- 这里做了中间层设计,也就是每个service都是一个'洋葱',一层层进,一层层出。
- 每个service都是异步执行的,就是说如果你的
plan
存在并行结构,那么两个并行的分支会一起执行。
rust
fn exec_next_node(ctx: Arc<Context>, node_code: &str) {
let nodes = ctx.plan.next(ctx.clone(), node_code);
...
for i in nodes {
match ctx.runtime.nodes.get(i.node_type_id.as_str()) {
...
};
//将service和middle封装成一个flow执行
let flow = Flow::new(i, ctx.clone(), middle);
tokio::spawn(async move {
let ctx = flow.ctx.clone();
let result = tokio::spawn(async move {
... //处理一些堆栈信息
//执行flow
if let Err(e) = flow.call().await {
//错误处理
} else {
//执行成功则继续执行下一个
Runtime::exec_next_node(ctx, code.as_str());
}
})
.await;
... //检查错误,和故障恢复
});
}
}
Service layer
为了方便service的实现,我们将service再包装一层。如下:
rust
pub trait ServiceLayer: Sync + Send {
//service的配置类型
type Config;
//输出结果
type Output;
//执行过程
async fn call(
&self,
code: String,
ctx: Arc<Context>,
cfg: Self::Config,
) -> anyhow::Result<Self::Output>;
}
再为这个包装层加一个自动json解析的实现。这样入参和出参可以直接绑定到struct。
rust
impl<T, In, Out> Service for LayerJson<T, In, Out>
where
T: ServiceLayer<Config = In, Output = Out>,
In: for<'a> Deserialize<'a> + Send + Sync,
Out: Serialize + Send + Sync,
{
async fn call(&self, flow: Flow) -> anyhow::Result<Output> {
... //类型解析和参数的初步处理
//解析入参
let cfg = match serde_json::from_str::<In>(node_config.as_str()) {
... //错误处理
};
//调用执行过程
let output = self.handle.call(code, ctx, cfg).await ? ;
//解析出参
let raw = match serde_json::to_value(&output) {
Ok(o) => o,
Err(e) => return anyhow::anyhow!("code[{}],output json error:{}", node_info, e).err(),
};
Output::new(raw).raw_to_ctx().ok()
}
}
测试
尾语
整体runtime模块还是比较简单,比较薄的