0%

从头手搓面向应用的AI框架

🌟 从头手搓面向应用的AI框架 🚀

为什么从头手搓 AI 框架,而不是使用现有框架?

💡 为啥我要从头手搓AI框架,而不是直接使用类似 langchain、AutoGen、LMQL 等现成框架呢?

最初我也尝试了多个框架(包括LangChain、AutoGen、LMQL、Outlines、MemGPT等),但它们都无法完全满足我的需求。我想要的是一个极度灵活可配置的AI应用框架,用来打造我设想的「真·AI PC」——而非当前大多数AI产品那种“远程AI服务+本地空壳”的模式。

在当前阶段,我认为面向应用的AI框架应聚焦于提示词(Prompt)的工程化——这是人类与AI交互的唯一桥梁。我的核心目标是:
✅ 将提示词转化为可编程的“函数”或“类”,支持自由组合、独立调用,并能持续迭代优化。

但是langchain对提示词的抽象只是总结的一些范式,而这些范式在我看来不太不实用,而且更糟糕的是直接将提示词内嵌入代码中,根本无法替换和迭代提示词。而AutoGen是以多智能体架构和事件驱动为核心实现复杂协作工作流;至于LMQL作为一种用于语言模型交互的查询语言更像是炫技;Outlines 聚焦在结构化文本的生成;MemGPT只想实现长久记忆, 这些就不多做评价了。

我想要的AI框架核心功能

我想要的是以提示词为核心的框架:

  1. 提示词即函数:让提示词象普通函数一样的使用,独立提示词可以与代码无缝双向调用,代码可以调用提示词获得结果,提示词也可以调用代码获得结果。
  2. 加密保护: 支持提示词加密,保障知识产权
  3. 模型中立: 提示词可适配不同模型及参数规模。
  4. 继承机制:支持提示词“类”的继承,如同面向对象编程。
  5. 简洁易用:提示词需简单易读写。
  6. 打包为应用:能将多个提示词打包成完整AI应用。
  7. 高度可扩展:支持任意能力的扩展,如:看(图生文),听(STT),说(TTS),画(文生图/视频)等等。
  8. 全栈兼容:跨平台支持(浏览器、服务器端),尽量统一语言减少维护成本(选型JavaScript)。

开发历程 🛠️

在2024年多方搜寻无果的情况下,2024年5月我开始从头写可编程提示词(Programmable Prompt Engine)AI规范,当然最开始不是叫这个名字,最开始叫AI Agent Framework,后来是表伟提议既然实质是让提示词成为可编程的软件工程,那么不妨就叫PPE,我想也对,这个概念更准确,AI Agent应该是以PPE提示词为基础打造,它其实是更上层的概念,遂更名为PPE。

既然要全栈,那就只能选JavaScript, 从前端到后端,从服务器到浏览器通吃,并且JS极度灵活和可扩展,而这正是我所需要的。

结构化对话消息

刚开始写的时候,还是很茫然无措的,没有项目可供参考,

最初就想着用YAML配置的方式简单的来结构化对话消息,然后既然提示词是函数那就肯定需要约定函数的输入和输出: 用input来约定提示词的输入参数, outputJson Schema来约定提示词的输出:

templateFormat: hf
prompt:
  messages:
    - role: system
      content: Carefully Think about the intent of following The CONVERSATION user provided. Output the json object with the Intent Category and Reason.
    - role: user
      content: |-
        The CONVERSATION:
        {{ conversation }}
input:
  conversation: "messages[1].content"
output:
  type: object
  properties:
    Intent:
      type: "string"
    Reason:
      type: "string"

感觉,这样很不自然方便,于是将结构化对话消息的输入/输出配置与对话内容分离:

---
input:
  conversation: {required: true}
output:
  type: object
  properties:
    Intent:
      type: "string"
    Reason:
      type: "string"
---
system: Carefully Think about the intent of following The CONVERSATION user provided. Output the json object with the Intent Category and Reason.
user: |-
  The CONVERSATION:
  {{ conversation }}

这样就感觉好多了。

按需格式化消息

在使用过程中,我发现我大多数时候都需要延迟对消息进行格式化,太早格式化消息会因为消息被提前固化,而导致消息中不是我期望的参数值。因此,加入了按需格式化消息功能,并设置为默认。该功能可以通过设置参数formatStringStrategy进行改变:

  • Raw: 不做任何的格式化
  • Deferred: 按需格式化,默认策略
  • Immediate: 立即格式化

例外,当执行$set, $get, $ret$echo操作时会立即对消息进行格式化。

---
# 修改本脚本的默认消息格式化策略:
formatStringStrategy: Immediate
---

提示词调用与代码集成

那么如何在提示词中使用(调用)AI赋值以及其他提示词或代码?于是有了高级替换:

---
# 导入js函数
import:
  - eval.js
---
# JOKE会被AI赋值,存放于: `prompt.JOKE` 中,供下次使用
assistant: "讲个笑话:[[JOKE]] 希望您喜欢!"
# 调用外部提示词`calculator`
user: "五加二等于 [[@calculator("5+2")]]"
# 调用函数代码`eval`
user: "五加二等于 [[@$eval("5+2")]]"
// eval.js
export function eval(value) {
  return ...
}

甚至支持了让AI只能在我给的列表中选择:

user: 你喜欢什么水果?
assistant: "我超喜欢[[FRUIT:|苹果|香蕉|橙子]]"

链式调用与参数传递

如何将最近消息内容传递给外部提示词?通过箭头符号->来实现动态参数传递:

user: "讲个笑话吧!"
assistant: "[[JOKE]]"
# 传入智能体的实际输入参数是: {content: "[这里是由AI生成的笑话]", target_lang: "葡萄牙语"}
-> translator(target_lang="葡萄牙语") -> $print

在依赖的js脚本中调用外部提示词

那么,反过来,如何在依赖的js脚本调用外部提示词呢?

export async function callPPEScriptDemo() {
  return await this.$exec({id: 'calculator', args:{ content: "6+3" }})
}

内置函数指令与逻辑控制

有些时候,我需要在提示词中根据不同条件来产生不同的提示词,甚至需要多条件匹配,于是开始内置相应的函数指令: $if, $match, …

---
input:
  - file
---
- $if: "this.file"
  then:
    - ...

并发测试服务提供商的不同模型:

$match(true, allMatches=true, parallel=true):
  :true:
    - $AI(pushMessage=false, model="openai://Qwen/Qwen2.5-Coder-7B-Instruct", apiUrl="https://api.siliconflow.cn/", apiKey="...")
  :true:
    - $AI(pushMessage=false, model="openai://THUDM/glm-4-9b-chat", apiUrl="https://api.siliconflow.cn/", apiKey="...")

随后,完善了循环控制函数指令: $for, $while

缓存机制与性能优化

然后,发现这个提示词"函数"执行耗时长,如果多次反复执行同一参数的"函数"就更浪费时间了,为啥不能缓存结果?于是有了LRU缓存执行结果,所有的外部调用脚本结果默认都会被缓存,当然可以简单通过参数开关memoized关闭该行为:

"[[@calculator(content="5+2", memoized=false)]]"

大模型的结构化输出

大模型必须对输出进行结构化(约定输出),程序代码函数才能处理。通常我们约定的是大模型结构化输出JSON对象。

我在实践中发现,对于推理任务,在不约束大模型输出类型的情况下,输出质量更好,而对于不需要推理的分类任务,则恰恰相反。关于这个,有人已经做了更详细的比较: Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models

另外,对大模型进行约定输出格式的文本格式也会影响输出质量,也就是告诉它如何输出的方式。用自然语言表达形式,好于直接给定json.

我测试下来,对于目前大多数 LLM 模型来讲 自然语言 > YAML > XML > JSON,当然具体还要看个别模型是否对某个格式又做了针对性的学习。

因此,我设计了多种结构化响应输出格式类型,用来自动将输出内容和格式告诉给大模型,以及将大模型的输出自动转换。

  • 多种结构化响应输出格式类型(response_format.type)支持:
    • JSON 格式
    • YAML 格式
    • 自然语言对象(NOBJ) 格式
    • 用JSON Schema格式设置好output.PPE就会自动解析AI生成的对应格式的内容为Object供代码使用.

约定PPE脚本输出内容始终用JSON Schema表示,而选择告知大模型的方式则在parameters.response_format.type中控制。这样,最终给到代码这边的结果始终是转换后的JSON对象。如果转换失败,会触发异常错误,通过设置forceJsonfalse禁止触发异常,这时当无法转换,就会返回文本内容。

---
output:
  type: "object"
  properties:
    target_text:
      type: "string"
parameters:
  # 使用后面的参数,将设置强制json输出格式,确保大模型总是输出正确的json格式.
  response_format:
    type: "json"
# forceJson: false
---

多轮对话支持

如何在同一个脚本中开启多轮对话,于是有了三个短划线(---) 或星号 *** 表示一个新的对话开始:

system: "您是一位AI助手。"
# 第一个分隔线作为对话的起点,第一个分隔线上面的对话内容会被隐藏,可以当作系统提示词,它们不会被输出或记录.
---
"10加18等于多少?"
# 执行AI,替换为AI传回的结果result
assistant: "[[result]]"
# 打印大模型传回的结果
$print: "?=result"
# 第二个分隔线下面开始新对话,回到第一次的起点
---
user: "10加12等于多少?"
# 执行AI,替换为AI传回的结果result
assistant: "[[result]]"

继承机制:提示词的“类”

基类定义char.ai.yaml):

type: type
# 定义该角色类型的输入配置
input:
  - name: {required: true}  # 必须提供的信息:角色的名字
  - description             # 可选的信息:对角色的描述
---
# 系统根据提供的信息来指导角色的行为
system: |-
  你是一个聪明、多才多艺的角色扮演者。
  你的任务是根据下面提供的信息完美地扮演角色。
  请说话就像{{name}}一样。
  你就是{{name}}。

  {{description}}

子类实现char_dobby.ai.yaml):

---
# 表示继承自char角色类型脚本
type: char
# 这里是根据“char”角色的一些具体设置
# 角色的名字
name: "Dobby"
# 对角色的描述
description: "Dobby 是哈利波特世界里的一个小精灵"
---
# 用户提问
user: "你是谁?"
---
# 根据角色设定的回答
assistant: "我是 Dobby。Dobby 很开心。"

多智能体群聊管理与私聊

群聊咋弄?通过roles约定使用的外部角色列表:

# guide.ai.yaml
---
description: "You are a professional guide. You can guide the user to complete the task."
name: "guide"
# 约定使用的角色列表,key为角色别名,值为角色脚本ID
roles:
  translator: char_translator
  dobby: char_dobby
---
system: You are a professional guide. You can guide the user to complete the task.
--- # New dialogue starts here
# 用户对dobby说,dobby脚本自动(一定)回复
user: "@dobby, I want to go to the moon."
# guide 对translator说
guide: "@translator, translate the dobby's message to chinese without explanation."
user: How to go to the moon?
dobby: "[[AI]]"

那么,如何在群聊中进行私聊?简单,在@dobby后加上参数@dobby(私)即可,这样dobby的回复只有该用户可见,聊天室中其他角色看不到。

通用大模型工具安全调用(仅限内置的LLM提供者)

让大模型自主使用工具(Tool Func)的确很方便,可以直接实现大模型的全自动化,但是如何保证安全呢?还有为啥必须要特别训练的大模型才能支持工具调用?有没有通用的办法,无需特别针对训练,让所有的大模型都支持工具调用?如何让工具的使用更加简单? 答案是有的,终于搞定,下面请看:

通过tools配置限制可调用的工具:

# 只要在脚本配置中设定允许智能体使用的工具`tools`, 这些工具`tools`对应PPE脚本ID,智能体就会在需要的时候自主调用这些工具完成任务。
---
tools:
  - weather
  - search
  - now
---
user: 现在几点了?今天上海的天气如何?
#智能体会根据配置的tools调用对应的工具: now, weather,并返回结果
assistant: "[[Answer]]"
# weather.ai.yaml
---
title: get weather information
input:
  - location:
      description: contain city, province(if any) and country
      example: city,province,country
      required: true
  - date:
      description: the weather information of the specified date
      example: 2025-02-04T18:07:42+08:00
      default: today
---
# 模拟返回天气信息
$echo: "上海的天气阴转多云,气温 2°C,相对湿度 60%,风向 东南风,风力 3-4级。"

为了进一步保障安全,除了限定执行脚本设定的工具列表外,用户(调用方)也可限制使用的工具列表,进行双重限制:

---
# 用户或上级脚本通过如下配置对智能体使用的工具进行权限控制
permissions:
  ai:
    call:
      - "w*"
      - "now"
---

通用大模型思维模式扩展 🧠 (仅限内置的LLM提供者)

DeepSeek 重新发现并开源了OpenAI(CloseAI)了秘而不宣的深度思维模式的训练办法,那么有没有一种可能,不需要额外训练,让AI大模型也能进行深度思考?应该可行,因为所谓的微调训练也是基于CoT的提示词进行,于是经过仔细思考,多番测试,终于搞定包括深度思维模式在内的多种思维模式(shouldThink)!

思维模式(shouldThink)我分为四种:直接回答不思考(off);先回答再思考(last);先思考再回答first;深度思考后再回答deep

---
# 思维模式默认为 `off`,设置为last,first,deep后自动启用。
shouldThink: deep
---
user: 树上有15只鸟,猎人开枪打中了2只,树上还有几只鸟?

Package 支持

目的:需要将相关的提示词和代码脚本、知识库放到一个文件包中,组成智能体,方便管理。

内建LLM提供者

当前,外置的llama-cpp server已经满足不了我的需求:

  1. 动态加载切换LLM大模型
  2. 提示词安全控制
    1. 系统模板反注入
    2. 提示词保护
  3. 自动适配硬件: 自动检测内存和GPU,并默认使用最佳计算层,自动分配gpu-layers以及上下文窗口大小, 以便从硬件中获得最佳性能,无需手动配置任何内容。
  4. 为指定的单词提升/降低权重,类似在stable-diffusion中(暂未实现)

通过这些设计,PPE 正在逐步支持我最初对「真 AI PC」的想象!🚀

尽管还处于WIP阶段,不过 PPE 已经实现了我设想的大部分功能。