2024-12-27 05:27:13
很多人都知道Prompt大神李继刚,他使用Lisp语法来写Prompt,把大模型指挥得服服帖帖。但我们很多时候没有办法把自己业务场景的Prompt改造成伪代码的形式。
相信不少人跟我一样,会使用Markdown格式来写Prompt,大部分时候没什么问题,但偶尔总会发现大模型返回的结果跟我们想要的不一样。
例如下图所示:
让大模型给我返回一个JSON,它返回的时候会用Markdown的多行代码格式来包装这个JSON。我后续要解析数据时,还得使用字符串切分功能把开头结尾的三个反引号去掉。即便我把system prompt里面的反引号去掉,改成:
1 |
你是一个数据提取专家,你能从一段文本中提取出所有结构化数据。并以J50N格式返回。返回格式示例: |
大模型有时候也会在返回时加上三个反引号。
今天要讲的这个超级简单的方法,就可以解决这种问题。这个方法就是,别使用Markdown,改成使用XML。
我们来看看把上面这个例子改成XML以后的效果:
返回的结果直接就满足要求。
在使用XML格式的Prompt时,对格式要求没有那么严格,它的核心目的就是让大模型能区分出Prompt里面的各个部分。因此标签的名字可以自己随便取,只要能表名意思就好了。例如上面我使用标签<response_example>
来表示我希望返回的数据长什么样。
可能有同学会觉得上面这个例子简单了,那么我们再来演示几个例子来说明用Markdown做Prompt有什么缺陷。
假设我需要让大模型阅读一篇文章,然后基于文章回答3个问题,我可能会这样写Prompt:
1 |
你是一个资深的文学家,你正在阅读一篇关于大模型的文章,请仔细阅读,然后基于文章的内容,回答三个问题: |
我们在代码里面,使用字符串的.format
把文章原文填充上去,然后整体发送给大模型来回答。看起来没什么问题对吧?但有时候,你会发现,大模型返回的内容只有一个问题的答案,并且这个问题还不是我指定的三个问题之一!
原来,我传入的这篇文章,它长这样:
1 |
第一段... |
所以原文的最后一句话影响到了Prompt,导致大模型完全忽略了我前面写的三个问题,而是真的在分享一下你对大模型的观点和看法
。
如果我们使用XML格式来改造这个Prompt,就可以完全解决这个问题。改造以后的Prompt如下:
1 |
<role>你是一个资深的文学家</role> |
这样一来,无论文章里面的内容怎么写,他都不会影响大模型回答我提的三个问题了。
有时候,我们的Prompt会比较长,里面包含了给大模型的回答示例,例如:
1 |
你是一个资深的文学家,你正在阅读一篇文章,请仔细阅读,然后基于文章的内容,按如下格式返回总结: |
看起来似乎没有问题对吧?那么我问你,## 规则
这个小节,你会不会觉得它和## 关键人物
混起来了?有时候你如果不停下来想一想,你可能会觉得大模型最后输出的内容可能是下面这个格式:
1 |
## 文章概览 |
但实际上## 规则
这个小节是独立的,是对整个大模型的回答做指导和限制,它不是答案的一部分。
使用Markdown经常会出现这样的问题,你很难分清楚两段话是分开的还是连在一起的。大模型实际上也会被误导,导致最后给出的结果不符合你的预期。
但如果改成XML,就完全不会有这种混淆:
1 |
<role>你是一个资深的文学家,你正在阅读一篇文章</role> |
可以看到,在这里我把XML和Markdown混在一起用了。这样写也完全没有问题。我们既通过XML让Prompt的结构更清晰了,同时又使用Markdown保持了Prompt的可读性。
写过RAG的同学,应该知道有时候我们需要让大模型标记出答案对应的参考文献。假设我从向量数据库里面找到了10条文本,他们都跟用户的问题相关,现在把这10条文本和对应的ID一起发送给大模型,并且指示大模型在返回答案时,每一句话都需要带上出处。如果使用XML,那么我们的Prompt可以写成:
1 |
<role>你是一个金融领域的专家,拥有丰富的投资经验</role> |
使用这种格式的Prompt,可以确保大模型返回的id确实就是对应原文的id。
Markdown形式的Prompt,虽然简单方便,但有时候会让大模型产生误解,从而得不出你想要的答案。换成XML格式的Prompt,大模型的回答质量会显著提升。
2024-12-24 07:16:14
今天,有同学在知识星球上给我提了一个问题:如何在Simplemind
中接入Azure的GPT接口。如下图所示。
在使用Python时经常会出现这样的情况,某一个第三方库,满足我们99%的需求,但碰巧有一个小需求不满足。遇到这种情况,有些同学会忍痛割爱,换一个库;还有一些同学,会继续使用这个第三方库,但是缺的那个功能,他就完全自己单独写;剩下的同学,可能是把这个第三方库下载下来,放到自己项目的根目录中,然后当做项目的一部分来修改并导入使用。今天我们就来讲一下这个问题。
前两个方法不需要多说什么。第三个方法从功能上来说没什么问题,但会给自己的项目引入大量其他代码,导致项目在做安全性检查、静态类型检查、Code Review时变得很麻烦。而且这个第三方库必须放到项目的根目录,否则在导入时,它的导入语句就跟正常pip
安装的导入语句不一样,以后如果官方库支持了这个缺失的功能,你得改很多个导入语句,才能再换回来,无形中引入了很多的不确定性和隐患。
我们今天想实现的功能是,调用这个二次开发的第三方库时,我自己的代码不需要做任何修改,甚至包括环境变量也不需要修改,直接像是调用任何pip安装的第三方库一样使用。
实际上,在pip
设计的时候,就已经预料到了这种情况。所以pip install
有一个-e
参数,可以用来指定某个特定文件夹里面的代码为一个可编辑的第三方库。对这个文件夹里面的所有修改会立刻生效,同时对于使用这个第三方库的代码来说,它不需要做任何修改,就像是在用正常的第三方库一样。它原本是用来方便在开发者自己写第三方库时,测试功能调用的,现在我们对现有的第三方库做二次开发,正好也可以使用它。
就以知识星球上面这个问题为例,来说明如何对Simplemind
进行二次开发。Simplemind
目前支持的大模型如下图所示:
其中的openai.py
代码如下,可以看到它初始化OpenAI连接对象时,只使用了api_key
参数。因此Simplemind
目前只支持OpenAI官方的GPT模型,无法使用Azure提供的GPT模型。
要使用Azure的GPT连接对象,我们需要使用如下的代码:
1 |
from openai.lib.azure import AzureOpenAI |
因为Azure的GPT和OpenAI的GPT除了初始化的参数不同,其他调用上的代码完全相同,因此我们可以继承openai.py
中的这个OpenAI
类,然后自己只需要复写def client
这个属性(注意,这里使用了@cached_property,所以它不是方法,而是属性),就可以让Simplemind
支持Azure的GPT了。
来看看具体的实现方法。从Github上面克隆Simplemind
的代码到本地,然后把它安装成可编辑的第三方库:
1 |
git clone [email protected]:kennethreitz/simplemind.git |
这三行代码就够了,这个时候,你在PyCharm中输入import simplemind
,会发现可以正常导入。如果你有OpenAI官方的API,那么你可以直接使用Simplemind
文档中的代码,立刻测试,会发现它和pip安装的没有任何区别。
现在,我们打开刚刚克隆下来的simplemind/simplemind/providers
文件夹,创建一个azure_openai.py
文件。里面的代码如下:
1 |
from .openai import OpenAI |
如下图所示。
然后编辑这个文件夹里面的__init__.py
文件,在其中添加上刚创建的这个新类,如下图所示。
改好了,以上就是全部的修改。现在开始编写调用代码,跟官方文档中的示例完全一样:
1 |
import simplemind as sm |
运行效果如下图所示,成功接上了Azure的GPT。
再来测试一下文档里面的记忆功能和工具调用,也全部正常运行:
国产大模型基本都支持直接使用openai
库调用,因此理论上使用这个方法,稍作修改,可以接入任意国产大模型。如果你改成使用LiteLLM
,甚至可以实现支持任意大模型。
2024-12-15 05:29:06
一说到RAG或者Agent,很多人就会想到LangChan或者LlamaIndex,他们似乎觉得这两个东西是大模型应用开发的标配。
但对我来说,我特别讨厌这两个东西。因为这两个东西就是过度封装
的典型代表。特别是里面大量使用依赖注入,让人使用起来非常难受。
假设我们要在Python里面模拟出各种动物的声音,那么使用依赖注入可以这样写:
1 |
def make_sound(animal): |
对于make_sound
函数,你不需要知道animal这个对象的bark
方法具体是怎么实现的,你只需要调用它并获取它的返回值就可以使用了。
当你要添加一个新的动物时,你只需要实现一个类,这个类里面有一个方法叫做bark
。那么,当这个动物需要发出声音时,把这个动物实例传入给make_sound
函数就可以了。
看起来很方便是吧?不同的动物类互不影响,屏蔽了细节。
上面这段代码,看起来很好,符合设计模式。如果这段代码是你自己写的,确实很方便。但如果这段代码是别人写的,并且你不知道它的细节,那么这些依赖注入就是灾难。我们来看看LlamaIndex文档里面给出的代码:
这段代码是一个简化版的RAG。把文本文件向量化并存入向量数据库。用户输入问题以后,程序自动去向量数据库查询数据。看起来代码非常简洁对吧?文本转向量的逻辑隐藏起来了,读写向量数据库的逻辑隐藏起来了。开发者不需要关心这些不重要的细节,只需要修改data
文件夹里面的文档就能索引原始文档。修改query_engine.query
的参数,就可以实现一个RAG。开发者把注意力放在了真正重要的地方,节约了时间,提高了效率。真是太完美了!
完美个屁!
上面这种狗屎代码,也就只能用来做个Demo。当开发者真正需要做二次开发的时候,上面的代码根本就不能用。
为什么不能用?因为我不知道query_engine.query
背后是怎么查询index
的。我也不知道VectorStoreIndex
在索引文档时,具体是怎么操作的。LlamaIndex似乎还沾沾自喜地在这个文档下面,预设了用户可能会问的几个问题:
它觉得用户要把文档拆分成不同的段落时,可以使用SentenceSplitter
。下面还有如何使用其他的向量数据库、查询更多文档、使用不同的大模型、使用流式返回……
看起来想得很周到对吧,它觉得用户能想到的需求,它都已经通过不同的类、不同的方法、不同的参数想到了。狗屎!
它根本不可能穷举用户所有的需求。例如:
这些需求,它根本想不到!而我作为开发者,我需要。但是我应该怎么插入到它的流程里面?
上图中,SentenceSplitter
的实例作为参数传给了VectorStoreIndex.from_documents
。那么如果我对拆分文档的逻辑有一些自己的要求,我怎么加进去?我自己写一个MyCustomSentenceSplitter
?现在问题来了,这个类有哪些方法应该怎么写?from_documents
里面调用的是哪个方法?上面make_sound
之所以看起来很简洁,是因为这个代码是我自己写的,我知道它会调用animal.bark
。但现在LlamaIndex是别人写的,我甚至都不知道它里面会怎么使用SentenceSplitter
。难道为了实现一个非常简单的文档分Token的逻辑,我还必须去翻阅它的语法文档甚至看它的源代码?那基本上要实现一个我想要的代码,我得把它整个文档先全部看完,源代码也看完,我才能开工。
LangChain和LlamaIndex使用大量的依赖注入,给开发者画了一个框,它内部控制了所有的流程。开发者不知道这个流程,开发者只能做完形填空,把代码缺的地方填写进去,就能有一个将将可以工作的程序出来。
但作为开发者,我需要的是控制这个流程,而不是去填空。
有人可能会说,那你可以去看LlamaIndex的源代码,看它内部是怎么查询向量数据库的,然后你自己写个类,把你自己的代码写进去啊。
如果有人这样想,我觉得你就是被人虐待了还在想是不是自己躺好一点让别人打你的时候没有那么累。
在使用做大模型应用开发时,我需要的是控制程序的流程。我需要简化的地方,是流程中的每个节点的调用方式,而不是简化这个流程。流程是我控制的,该不该简化,我自己知道!
来看看Requests作者Kenneth Reitz的新作品:SimpleMind。这是我认为符合AI for Human
的项目。Kenneth真正知道使用这个库的人需要什么。我们来看看SimpleMind
的使用方法:
1 |
|
1 |
class SimpleMemoryPlugin(sm.BasePlugin): |
1 |
def get_weather( |
SimpleMind简化了我调用大模型这个节点。那么如果我就能自己来控制程序的逻辑了。还是以RAG为例,我希望在简化了节点以后,代码是这样的:
1 |
def rag_ask(question): |
其中,text2embedding
/query_vector_db
/rerank
/ask_llm
这几个函数,我能够使用简单的几行代码就实现,我可以在这个流程里面的任意两个节点之间,随意添加我自己的逻辑。这才是我想要的。
实话实说,看到LangChain的使用方法,我就觉得这东西是一群写Java或者写C#的人,强行来写Python搞出来的缝合怪,整个代码我看不到Python的任何编码哲学,我能看到的只有过度封装,为了抽象而抽象。LangChain的作者,根本就没有站在Python开发者的角度制定它的使用方法。
2024-11-13 05:20:33
假设你正在写后端代码,其中一个函数的功能是传入文章id,返回文章详情。因为项目比较大,因此在定义函数时,把类型标注加上,标明了参数的类型和返回的类型。例如:
1 |
from typing import List |
现在,当你拿到返回的detail变量时,IDE的自动补全就可以正常工作了,如下图所示。
你想让这个函数支持批量查询文章详情的功能,代码类似这样:
1 |
def query_article_detail(article_id: int | List[int]) -> ArticleDetail | List[ArticleDetail]: |
如果传入的参数是int类型的文章id,那么就返回这篇文章的详情ArticleDetail
对象。如果传入的是文章列表,那么就返回ArticleDetail
对象列表。
现在问题来了,由于query_article_detail
函数返回的数据类型不同,如何让IDE的自动补全能够正确提示呢?例如当我们传入了一个文章id列表,但是却直接读取返回数据的.content
属性,在IDE上面看不出任何问题,如下图所示。但显然会报错,因为此时的detail
变量的值是一个列表。列表是没有.content
属性的。
有没有什么办法能够让IDE根据query_article_detail
参数的类型,提示我们对返回数据的使用是否正确呢?
这个场景下,就可以使用Python的typing
模块中的@overload
装饰器,实现函数重载来提示。示例代码如下:
1 |
from typing import List, overload |
在定义函数之前,先使用@overload
装饰器,装饰两次函数名。每一次使用不同的参数:
1 |
|
这两个函数都是空函数,函数体用三个点代替。当然你也可以使用pass
。而你真正的query_article_detail
放到最下面。现在,当我们对detail
对象使用自动补全时,IDE就能根据参数的类型来补全对应的值了。
当传入参数是单个id时,如下图所示:
当传入的参数是id列表时,如下图所示:
需要注意的时,所有重载的函数与真正执行的函数,函数名必须全部相同,如下图所示:
并且,真正实现功能的函数,必须放在重载函数的下面。
使用这种方式,以后即时别的文件导入并使用你这个函数,你也不用担心它用错数据类型了。
2024-11-11 01:51:32
我们知道,在写Python时,使用IDE的自动补全功能,可以大大提高代码的开发效率。使用类型标注功能,可以让IDE知道应该怎么做自动补全。
当我们没有类型标注时,IDE并不知道函数的某个参数是什么东西,没有办法做补全,如下图所示。
但当我们把类型标注加上以后,IDE就能正常补全了,如下图所示:
这样做,需要从另一个文件中,把这个参数对应的类导入到当前文件里面,然后把类作为类型填写到函数参数后面。咋看起来没有什么问题,并且我,还有很多看文章的同学,应该经常这样写类型标注的代码,从而提高代码的开发效率。
但如果你的项目规模大起来以后,你就会遇到几个比较麻烦的问题:
model.py
中导入了Detail
这个类。如果我在model.py
文件的开头,还有from aaa import bbb
,而在aaa.py
文件开头,又有from ccc import ddd
;在ccc.py
开头,又有from xxx import yyy
……这个导入链条就会变得很长。虽然Python对模块导入已经做了缓存,多次执行from xxx import yyy
时,只有第一次会生效,后面都是读取缓存,但读取缓存也会消耗一些时间。如果你引入一个类,仅仅是为了做类型标注,那么这个问题实际上非常好解决。在Python的typing模块里面,有一个常量,叫做TYPE_CHECKING
,它就是为了解决这个问题而设计的。在你使用python xxx.py
来启动代码时,TYPE_CHECKING
的值是False
。但当IDE的类型检查或者Mypy
这种静态类型检查工具运行时,TYPE_CHECKING
的值是True
。
因此,我们可以使用下面这段代码,来提高代码的运行效率,同时规避循环依赖的问题:
1 |
from typing import TYPE_CHECKING |
注意,在函数参数的类型标注里面,类YYY
需要以字符串的形式写出。如下图所示:
使用这种方法,在写代码时,IDE能够正确的做自动补全。在Mypy
做静态类型检查时,也能过正常通过检查。但当代码实际运行时,会自动忽略这个导入的类,从而避免对代码的运行效率造成影响。
2024-11-01 06:34:28
当我们使用大模型生成JSON,或者爬虫抓取数据时,可能会遇到一些有异常的JSON,例如:
1 |
{"profile": {"name": "xx", "age": 20} |
1 |
{name: 青南, age: 20, salary: "99999999, } |
1 |
{"name": "青南", "age": 20, "salary: "\"very big\\""} |
Python的json模块解析这些有问题的JSON时就会报错。这个时候,可以使用一个叫做json-repair
的第三方库来解决问题。
使用pip
就可以安装json-repair
。导入以后,就可以像json.loads
一样使用了,
运行效果如下图所示:
对于双引号异常和反斜杠异常,也能正常解析:
字符串型的Python字典,也能正常解析,如下图所示:
使用这个模块,在很大程度上就能避免JSON解析不对的问题了。