赞
踩
问题
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 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等库里的问题。
经过这些思考,最终我们得到了一个新的设计。
Prompt 函数
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)
和前面的代码,有三个地方发生了变化:
@byzerllm.prompt(render="jinja2") 里多了一个参数 render, 该值被设计为 jinja2了。
generate_answer 里的 Doc 实现,采用了 jinjia2 语法。
参数我改成了 name,task_count,tasks。其中 tasks 是一个比较复杂的结构类型。
generate_answer 在 doc 中实现了对 tasks 参数做了做循环和使用,从而实现更好的模板控制。
最后来个回顾
我们提出了 Prompt函数, Prompt 类的概念,将Prompt 和 函数实现了完美结合,Prompt函数会自动将入参渲染到 文本中,与此同时,Prompt函数还支持字符串和复杂结构返回。
Prompt函数的核心是利用文本替换了代码实现,通过引入jinjia强大的模板能力,可以实现复杂的参数渲染,并且可以通过配置开关选择返回渲染后的Prompt或者大模型执行后的Prompt。
最后的最后
Byzer-LLM 在大语言模型和编程融合上做了非常多的探索,大家还可以看看我们往期的文章:
函数实现越通用越好?来看看 Byzer-LLM 的 Function Implementation 带来的编程思想大变化
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。