AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


最好的Prompt管理和使用依然是 Class 和 Function - 继续让LLM和编程语言融合
发布日期:2024-04-18 15:02:17 浏览次数: 1872


问题

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


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

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

为了能够解决缩进问题,他们使用了 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 modulegenerate_answer = dspy.ChainOfThought("context, question -> answer")
# Option 2: Or pass full notation signature to ChainOfThought modulegenerate_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
@functiondef 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 osos.environ["RAY_DEDUP_LOGS"] = "0" 
import rayfrom byzerllm.utils.retrieval import ByzerRetrievalfrom byzerllm.utils.client import ByzerLLM,LLMRequest,LLMResponse,LLMHistoryItem,InferBackendfrom 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 rayfrom byzerllm.utils.client import ByzerLLMimport 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 rayfrom byzerllm.utils.client import ByzerLLMimport 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 rayimport functoolsimport inspectimport byzerllmimport pydantic
ray.init(address="auto",namespace="default",ignore_reinit_error=True)
class ByzerProductDesc(pydantic.BaseModel):byzer_retrieval: strbyzer_llm: strbyzer_agent: strbyzer_perf: strbyzer_evaluation: strbyzer_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 rayimport functoolsimport inspectimport byzerllmimport pydanticfrom 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。



53AI,企业落地应用大模型首选服务商

产品:大模型应用平台+智能体定制开发+落地咨询服务

承诺:先做场景POC验证,看到效果再签署服务协议。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

与创始人交个朋友

回到顶部

 
扫码咨询