最好的Prompt管理和使用依然是 Class 和 Function - 继续让LLM和编程语言融合

问题

Python 语言其实已经是对字符串模板最友好的语言之一了,但是实际写出来是这样的:

dba4841ba31d8dcdc8de8c576533e23d.png

实际prompt 一般都会远大于上面的例子。而且我们可以看到缩进也完全break掉了,这在Python中会导致源码很难看。如果你在方法里定义了prompt, 那么缩进就更是灾难了。

上面的基本思路是在定义一个或者多个python文件,里面专门写很多上面例子的大段大段的话,然后使用的时候还需要配合使用专门封装函数(比如PromptTemplate对象)做渲染。比如流行的langchain/llama_index的做法就是这样:

28aad11a3ddb0fd0e878680059b97ea7.png

为了能够解决缩进问题,他们使用了 Tuple的方式,虽然美观了,但是写和修改都会痛苦万分。另外用起来其实也很不方便,还是老套路。

而且,Python已经是对“文本”特别友好的语言了,其他语言你估计会吐血。

归根结底,就是“prompt 编程” 和“传统编程” 是不match的,导致各种别扭和难受。

业绩已有的探索

为了解决上面的问题,业界也做了很多探索。比如DSPy 就提供了很多常见模板,然后以class的方式填写:

class GenerateAnswer(dspy.Signature):
    """Answer questions with short factoid answers."""
    context = dspy.InputField(desc="may contain relevant facts")
    question = dspy.InputField()
    answer = dspy.OutputField(desc="often between 1 and 5 words")

很像 Pydantic, 然后他会根据你的这个类,和内置的模板做结合:

# Option 1: Pass minimal signature to ChainOfThought module
generate_answer = dspy.ChainOfThought("context, question -> answer")


# Option 2: Or pass full notation signature to ChainOfThought module
generate_answer = dspy.ChainOfThought(GenerateAnswer)


# Call the module on a particular input.
pred = generate_answer(context = "Which meant learning Lisp, since in those days Lisp was regarded as the language of AI.",
                       question = "What programming language did the author learn in college?")

实际上配置了一个 ChainOfThout的模板,自动从类提取信息,然后做渲染。实际上这个方案没有从根本上解决prompt的管理,如果没有模板呢?而且用户需要学习大量API,我觉得大概率用的人不会多,心智门槛太高了。

另外还有一个心智门槛更高的SGlang 里面用到的语法:

from sglang import function, system, user, assistant, gen, set_default_backend, RuntimeEndpoint


@function
def multi_turn_question(s, question_1, question_2):
    s += system("You are a helpful assistant.")
    s += user(question_1)
    s += assistant(gen("answer_1", max_tokens=256))
    s += user(question_2)
    s += assistant(gen("answer_2", max_tokens=256))


set_default_backend(RuntimeEndpoint("http://localhost:30000"))


state = multi_turn_question.run(
    question_1="What is the capital of the United States?",
    question_2="List two local attractions.",
)

这种不但把 Prompt搞复杂了,还把 Python代码搞复杂了,说实在的,我不会多看一眼。。。

Byzer-LLM 解决方案

我经过很长的一段时间实践,我发现要回归到本源,也就是你终究是在写代码, prompt只是代码里的一部分,那么要解决prompt的管理,还是要从 Class/Function 这种方式去解决。Class/Function就是编程语言的一个抽象范式,帮你管理和使用各种功能。同理,一段Prompt就应该是一个Function, 多个Prompt应该就可以组成一个类,这些prompt要组成一个类,就意味着他们有内在的关系。

此外,我们还要解决文本在 Python 中缩进的问题,避免我们提到的LlamaIndex等库里的问题。

经过这些思考,最终我们得到了一个新的设计。

  1. Prompt 函数

  2. Prompt 类

后续文中的效果大家都可以在 Byzer-LLM 0.1.44版本体验到。

首先,我们部署一个 SaaS模型:

import os
os.environ["RAY_DEDUP_LOGS"] = "0" 


import ray
from byzerllm.utils.retrieval import ByzerRetrieval
from byzerllm.utils.client import ByzerLLM,LLMRequest,LLMResponse,LLMHistoryItem,InferBackend
from byzerllm.utils.client import Templates


ray.init(address="auto",namespace="default",ignore_reinit_error=True)  


llm = ByzerLLM()
llm.setup_num_workers(2).setup_gpus_per_worker(0)


llm.deploy(pretrained_model_type="saas/sparkdesk",
           udf_name="sparkdesk_chat",
           infer_params={
            "saas.appid":"xxxx",            
            "saas.api_key":"xxxx",            
            "saas.api_secret":"xxxx",            
            "saas.gpt_url":"wss://spark-api.xf-yun.com/v3.5/chat"
           })

这里用了讯飞的星火大模型,token管饱。

Prompt 函数

接着,我们模拟一个非常典型的RAG Prompt,我们使用函数来做 Prompt的载体:

@llm.prompt()
def generate_answer(context:str,question:str)->str:
    '''
    Answer the question based on only the following context:
    {context}
    Question: {question}
    Answer:
    '''
    pass

我们定义了一个叫做 generate_answer, 他的doc其实就是我们要的prompt, 这里,这个Prompt包含了一些变量: {context},{question}。这个变量可以通过generate_answer的方法进行传递。

方法本身不需要做任何实现,唯一和别人不同的地方是,他有个 @llm.prompt() 注解。That's all。

context='''
Byzer产品栈从底层存储到大模型管理和serving再到应用开发框架:
1. Byzer-Retrieval, 一个支持向量+搜索的混合检索数据库。
2. Byzer-LLM, 可以衔接SaaS/开源模型,能部署,可以统一入口。
3. Byzer-Agent ,一套分布式 Agent 框架,做为应用开发框架
4. ByzerPerf, 一套性能吞吐评测框架
5. ByzerEvaluation, 一套大模型效果评测框架 (未开源)
6. Byzer-SQL, 一套全SQL方言,支持ETL,数据分析等工作,并且支持大模型的管理和调用(model as UDF),方便数据预处理。
'''
print(generate_answer(context=context,question="Byzer SQL是什么?"))

假设我们从数据库里召回了一段上下文,然后从HTTP接口拿到了一个问题,我们直接调用 generate_answer 方法即可完成,输出如下:

Byzer SQL 是一个全SQL方言,支持ETL、数据分析等工作,并且支持大模型的管理和调用(model as UDF),方便数据预处理。

从上面的例子可以看到,prompt 被转变成一个函数,而这个函数的实现,实际上就是 doc ,而不是传统意义上的你的实现代码(这里是pass)。现在,我们真正意义上实现了,doc就是实现。

Doc 里的变量可以和函数入参自动结合渲染,并且最终被大模型运行。

并且由于 Doc 提供了良好的缩进和多行控制能力,所以整个观感也非常好。你写个一百个函数,也不会觉得格式乱。

为了解决一个问题,你可能需要和多个prompt,那么这些prompt 函数就可以放在一个类。下面我们来看看怎么解决。

Prompt 类

import ray
from byzerllm.utils.client import ByzerLLM
import byzerllm


ray.init(address="auto",namespace="default",ignore_reinit_error=True)  


class RAG():
    def __init__(self):        
        self.llm = ByzerLLM()
        self.llm.setup_template(model="sparkdesk_chat",template="auto")
        self.llm.setup_default_model_name("sparkdesk_chat")        
    
    @byzerllm.prompt(lambda self: self.llm)
    def generate_answer(self,context:str,question:str)->str:
        '''
        Answer the question based on only the following context:
        {context}
        Question: {question}
        Answer:
        '''
        pass

这里,我定义了一个叫 RAG 的类,然后初始化的时候初始化了一个大模型client。然后里面定义了一个叫 generate_answer 的方法,这个方法和前面的方法是完全一样的。唯一的区别他现在是一个实例方法。

注解也有一点点变化,使用 byzerllm 模块的prompt 装饰器。其中第一个参数是一个lambda表达式,这个表达式会传递 RAG 实例的 llm 引用。

定义完上面的代码后,我们现在就可以直接使用了:

t = RAG()
print(t.generate_answer(context=context,question="Byzer SQL是什么?"))

如果你希望不要执行这个prompt,而是拿到渲染后的prompt,那么可以这么做:

import ray
from byzerllm.utils.client import ByzerLLM
import byzerllm


ray.init(address="auto",namespace="default",ignore_reinit_error=True)  


class RAG():
    def __init__(self):        
        self.llm = ByzerLLM()
        self.llm.setup_template(model="sparkdesk_chat",template="auto")
        self.llm.setup_default_model_name("sparkdesk_chat")        
    
    @byzerllm.prompt()
    def generate_answer(self,context:str,question:str)->str:
        '''
        Answer the question based on only the following context:
        {context}
        Question: {question}
        Answer:
        '''
        pass


t = RAG()
print(t.generate_answer(context=context,question="Byzer SQL是什么?"))

和第一版代码的唯一区别就是没有传递 llm 引用。这里会直接输出渲染后的prompt:

Answer the question based on only the following context:


Byzer产品栈从底层存储到大模型管理和serving再到应用开发框架:
1. Byzer-Retrieval, 一个支持向量+搜索的混合检索数据库。
2. Byzer-LLM, 可以衔接SaaS/开源模型,能部署,可以统一入口。
3. Byzer-Agent ,一套分布式 Agent 框架,做为应用开发框架
4. ByzerPerf, 一套性能吞吐评测框架
5. ByzerEvaluation, 一套大模型效果评测框架 (未开源)
6. Byzer-SQL, 一套全SQL方言,支持ETL,数据分析等工作,并且支持大模型的管理和调用(model as UDF),方便数据预处理。


Question: Byzer SQL是什么?
Answer:

Prompt 函数返回值

前面的例子,我们的Prompt函数都是直接返回字符串。如果我希望他返回结果化数据呢?当然没问题,Prompt 函数可以支持两种类型:Pydantic 和 Str. 我们来举个返回值是 Pydantic Model 的例子:

import ray
import functools
import inspect
import byzerllm
import pydantic


ray.init(address="auto",namespace="default",ignore_reinit_error=True)  


class ByzerProductDesc(pydantic.BaseModel):
    byzer_retrieval: str
    byzer_llm: str
    byzer_agent: str
    byzer_perf: str
    byzer_evaluation: str
    byzer_sql: str


class RAG():
    def __init__(self):        
        self.llm = ByzerLLM()
        self.llm.setup_template(model="sparkdesk_chat",template="auto")
        self.llm.setup_default_model_name("sparkdesk_chat")        
    
    @byzerllm.prompt(lambda self: self.llm)
    def generate_answer(self,context:str,question:str)->ByzerProductDesc:
        '''
        Answer the question based on only the following context:
        {context}
        Question: {question}
        Answer:
        '''
        pass


t = RAG()


byzer_product = t.generate_answer(context=context,question="Byzer 产品列表")
print(byzer_product.byzer_sql)
## output: 一套全SQL方言,支持ETL,数据分析等工作,并且支持大模型的管理和调用(model as UDF),方便数据预处理

在这个例子中,我们定义了一个叫做 ByzerProductDesc 的类,并且作为 generate_answer 的返回值。

现在,当我们运行 prompt 函数 generate_answer 的时候,返回的就是 ByzerProductDesc对象而不是字符串。

Prompt函数可编程性

Prompt 函数本质是 Doc 部分取代了传统编码,但是 Doc 本省就是“程序”,我们称为 Prompt Programming. 前面我们看到, Prompt 函数里的 Doc 仅仅能填写一些变量,这些变量会自动被函数入参替换。如果我希望 Doc 也是真正的可编程,包括对参数做处理,怎么办?当然没问题,Byzer-LLM 引入了 Jinjia 模板技术。让我们看看:

import ray
import functools
import inspect
import byzerllm
import pydantic
from byzerllm.utils.client import ByzerLLM


ray.init(address="auto",namespace="default",ignore_reinit_error=True)  


data = {
    'name': 'Jane Doe',
    'task_count': 3,
    'tasks': [
        {'name': 'Submit report', 'due_date': '2024-03-10'},
        {'name': 'Finish project', 'due_date': '2024-03-15'},
        {'name': 'Reply to emails', 'due_date': '2024-03-08'}
    ]
}




class RAG():
    def __init__(self):        
        self.llm = ByzerLLM()
        self.llm.setup_template(model="sparkdesk_chat",template="auto")
        self.llm.setup_default_model_name("sparkdesk_chat")        
    
    @byzerllm.prompt(render="jinja2")
    def generate_answer(self,name,task_count,tasks)->str:
        '''
        Hello {{ name }},


        This is a reminder that you have {{ task_count }} pending tasks:
        {% for task in tasks %}
        - Task: {{ task.name }} | Due: {{ task.due_date }}
        {% endfor %}


        Best regards,
        Your Reminder System
        '''
        pass


t = RAG()


response = t.generate_answer(**data)
print(response)

和前面的代码,有三个地方发生了变化:

  1.  @byzerllm.prompt(render="jinja2") 里多了一个参数 render, 该值被设计为 jinja2了。

  2. generate_answer 里的 Doc 实现,采用了 jinjia2 语法。

  3. 参数我改成了 name,task_count,tasks。其中 tasks 是一个比较复杂的结构类型。

generate_answer 在 doc 中实现了对 tasks 参数做了做循环和使用,从而实现更好的模板控制。

最后来个回顾

我们提出了 Prompt函数, Prompt 类的概念,将Prompt 和 函数实现了完美结合,Prompt函数会自动将入参渲染到 文本中,与此同时,Prompt函数还支持字符串和复杂结构返回。

Prompt函数的核心是利用文本替换了代码实现,通过引入jinjia强大的模板能力,可以实现复杂的参数渲染,并且可以通过配置开关选择返回渲染后的Prompt或者大模型执行后的Prompt。

最后的最后

Byzer-LLM 在大语言模型和编程融合上做了非常多的探索,大家还可以看看我们往期的文章:

函数实现越通用越好?来看看 Byzer-LLM 的 Function Implementation 带来的编程思想大变化

Python 和 LLM 的完美融合之路 (再谈 Function Impl)

给开源大模型带来Function Calling、 Respond With Class

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值