Deze blog is het tweede deel van een serie blogs, waarin ik ga proberen een uitgebreide overview van agentframeworks te maken door ze te gebruiken bij het bouwen van een aantal kleine agentic applicaties. Deel 1 is hier terug te lezen.
- 1. Overzicht
- 2. Inleiding
- 3. Ell principes
- 3.1 Prompts als programma’s
- 3.2 Prompt engineering is een iteratief proces
- 3.3 Lexical closures
- 3.4 Prompt engineering libraries shouldn’t interfere with your workflow
- 4. Plus en minputen van Ell in het kort
- 5. Database structuur
- 6. Prompt A/B testing
- 6.1 Casus
- 6.2 Welke metrics gaan we gebruiken?
- 6.3 Snelheid
- 6.4 Inhoudelijke kwaliteit
- 6.5 Database updaten
- 6.6 LMP's en overige functies aanmaken
- 6.7 Nieuwsartikelen ophalen
- 6.8 LMP's voor nieuwssamenvattingen
- 6.9 Evaluatiefuncties
- 6.10 Hulpfuncties
- 6.11 Hoofdlogica
- 7. Evaluatie
- 7.1 check de database
- 7.2 Het maken van een rapportage
- Conclusie
Note vooraf: in deze blog gebruik ik zo nu en dan een anthropomorfisme om acties of output van AI te beschrijven. AI modellen kunnen uiteraard niet “denken” of “begrijpen”.
1. Overzicht
🧑🏼💻Github van Ell
🧑🏼💻Github van AB test systeem
2. Inleiding
Van de frameworks die ik van plan ben te behandelen, is Ell waarschijnlijk de minst bekende, althans op dit moment. Op het moment van schrijven heeft de Github library 3600 stars. Voor het contrast: voor Langchain zijn dit er meer dan 92.000. Dat gezegd hebbende is mijn inschatting na er even mee gewerkt te hebben, dat dat wel gaat veranderen. Een veel gehoorde kritiek op LangChain is dat er teveel focus ligt op het declareren van intenties (zoals het beschrijven van het gewenste gedrag van een agent) zonder expliciet te specificeren hoe die intenties moeten worden uitgevoerd. Daarnaast hoor je veel dat Langchain teveel onnodige abstracties heeft die de controle over de uitvoering verminderen en de complexiteit vergroten, waardoor de code moeilijk te debuggen en te begrijpen is. Ell lijkt hier een soort antwoord op te zijn. De maker van Ell werkt voor OpenAI maar Ell is prima te gebruiken als framework voor welke LLM provider dan ook.
3. Ell principes
3.1 Prompts als programma’s
Meestal, wanneer mensen aan prompts denken, denken ze aan een stuk tekst met instructies voor een AI model. Ell benadert een prompt net iets anders met het principe "prompts are programs, not strings". In plaats van prompts te behandelen als eenvoudige tekststrings, benadert Ell ze als volwaardige programma's. Dit betekent dat een prompt niet alleen bestaat uit de uiteindelijke tekst die naar het taalmodel wordt gestuurd, maar uit alle code die leidt tot die tekst. Ell noemt dit concept een Language Model Program (LMP). Deze benadering biedt vaak meer flexibiliteit en controle, omdat de volledige kracht van Python kan worden benut bij het construeren van prompts.
import ell
@ell.simple(model="gpt-4o")
def hello(name: str):
"""You are a helpful assistant.""" # System prompt
return f"Say hello to {name}!" # User prompt
greeting = hello("Sam Altman")
print(greeting)
Basis voorbeeld van een LMP
3.2 Prompt engineering is een iteratief proces
De benadering van een prompt als een programma, of LMP, is een gevolg van de manier waarop de Ell makers naar prompt engineering kijken. In de visie van Ell is prompt engineering een iteratief proces waarbij men herhaaldelijk kleine aanpassingen maakt aan prompts, deze test, en de resultaten beoordeelt. Deze kijk op prompt engineering vraagt echter wel om een gestructureerde systematische aanpak. Want hoe hou je op een slimme manier de kwaliteit van een prompt bij wanneer je volgens deze methodiek te werk gaat? Veel van de functionaliteiten die Ell biedt zijn herleidbaar als oplossing voor deze vraagstukken.
Het sterkste punt daarbij is wat mij betreft de versioning en logging van alle LMP’s, en de structuur van deze logging. Hoewel het idee van logging en versioning voor prompts op het eerste gezicht eenvoudig lijkt, brengt een te simpele implementatie verschillende uitdagingen met zich mee:
- Onvolledige context: Simpelweg de tekst van een prompt opslaan is vaak onvoldoende, omdat de context waarin de prompt wordt gebruikt cruciaal kan zijn voor de werking ervan.
- Afhankelijkheden: Prompts kunnen afhankelijk zijn van andere functies of variabelen in de code. Deze worden niet meegenomen in een eenvoudige tekstuele logging.
- Versiecontrole: Het kan lastig zijn om verschillende versies van prompts te vergelijken en te beheren zonder een gestructureerd systeem.
- Reproducibility: Zonder volledige context is het moeilijk om exact dezelfde resultaten te reproduceren.
- Inefficiëntie: Handmatige logging en versioning kunnen tijdrovend en foutgevoelig zijn.
3.3 Lexical closures
Om deze problemen op te lossen, maakt Ell gebruik van het concept van lexical closures. Een lexicale closure omvat niet alleen de broncode van een functie, maar ook alle globale en vrije variabelen waarvan deze afhankelijk is. Dit betekent dat Ell de volgende elementen vastlegt:
- De broncode van de LMP functie zelf.
- Alle afhankelijkheden van de functie, inclusief andere functies en variabelen.
- De volledige context waarin de LMP wordt uitgevoerd.
Door deze aanpak kan Ell een minimale set van broncode produceren die nodig is om de functie exact te reproduceren. Dit zorgt ervoor dat elke versie van een LMP volledig zelfstandig is en onafhankelijk kan worden uitgevoerd of geanalyseerd.
Doordat Ell zo goed is in versiebeheer, hebben ze onder meer een super handige functie, waarmee je een LLM automatisch voor mensen leesbare commitmessages voor elke aanpassing van een prompt laat genereren en deze lokaal opslaat:
3.4 Prompt engineering libraries shouldn’t interfere with your workflow
Het laatste principe van Ell dat ik even wil uitlichten is het uitgangspunt dat prompt engineering libraries geen invloed zouden moeten hebben op jouw manier van werken. Veel meer dan andere agentic frameworks slaagt Ell er in om echt “lightweight” te zijn, waardoor veel beter te volgen is wat er in de code gebeurt.
4. Plus en minputen van Ell in het kort
- Lichtgewicht framework met zo min mogelijk onnodige abstracties en goed te begrijpen Python structuur.
- Hele fijne versioning en visualisatie functionaliteiten.
- Super goed doordachte uitgangspunten/principes.
- Ell is volledig opensource
- Ell is nog vol op in ontwikkeling, waardoor veel functionaliteiten nog niet beschikbaar zijn. Dit is vooral een nadeel wanneer je dingen als tool calling of structured output nodig hebt.
- In Ell studio wordt de flow van een applicatie nog niet altijd goed gevolgd. Dit is vooral het geval als je andere decorators dan de ell-simple decorator gebruikt.
- Ell is alleen beschikbaar als Python library
5. Database structuur
Default gebruikt ell een sqlite database waarin de prompts lokaal worden bewaard:
Database structuur
De hoofdtabel 'serializedlmp' slaat de essentiële informatie van elke LMP op, waaronder een unieke 'lmp_id', de naam, bron, afhankelijkheden, type en verschillende parameters. Deze tabel vormt de basis voor het versiebeheersysteem van ell, waarbij elke versie van een LMP wordt opgeslagen met een unieke hash. De 'invocation' tabel registreert elke keer dat een LMP wordt aangeroepen, met details zoals latentie, tokengebruik en tijdstempel. Deze tabel is gekoppeld aan 'serializedlmp' via de 'lmp_id', wat een directe relatie legt tussen een LMP en zijn gebruik.
Mocht je meekijken met mijn github code, hier een utility script om de database structuur in een markdown te plaatsen.
Mocht je de code als package geïnstalleerd hebben, via pip install -e .
, kun je ipv het script handmatig uit te voeren ook de command ell_dbstructure
gebruiken in je terminal (meer info in de github Readme).
6. Prompt A/B testing
Nu we de opzet van de database kennen kunnen we zelf een kleine applicatie gaan bouwen. Gezien de nadruk van het framework op het incrementeel verbeteren van prompts, leek het me wel toepasselijk om een voorbeeld te bouwen waarbij we prompts kunnen A/B testen op basis van een aantal door onszelf bepaalde variabelen.
6.1 Casus
Laten we ons de situatie voorstellen dat we een agentic applicatie hebben waarmee we sport nieuws berichten samenvatten. De applicatie haalt berichten op uit de rss feed van de NOS, en vat deze samen.
Het belangrijke deel van de code is natuurlijk de summarize_news functie:
@ell.simple(model="gpt-4-turbo", temperature=0.1)
def summarize_news(title: str, content: str) -> str:
"""Summarize the given news article with a focus on factual reporting."""
return f"""You are an expert news editor known for your ability to distill complex news stories into clear, concise summaries. Your task is to summarize the following news article in 2-3 sentences, focusing on the most crucial facts and key points. Maintain a neutral, objective tone and ensure all information is accurate.
Title: {title}
Content: {content}
Summary (2-3 sentences):"""
Laten we zeggen dat je een 2e versie van dit prompt wil testen, waarbij je ook een ander model gebruikt:
@ell.simple(model="gpt-4o-mini", temperature=0.9)
def summarize_news_v2(title: str, content: str) -> str:
"""Summarize the given news article make it entertaining."""
return f"""You are a gossip news editor known for your ability to entertain. Your task is to summarize the following news article in 2-3 sentences, focusing on the most fun points. Maintain a sensational and engaging tone
Title: {title}
Content: {content}
Summary (2-3 sentences):"""
6.2 Welke metrics gaan we gebruiken?
Voor dat we de a/b test tussen deze 2 prompts kunnen gaan bouwen, moeten we natuurlijk eerst bepalen op basis waarvan we de antwoorden van een LLM evalueren. Hierbij is de lastigheid in dit geval dat de input (het nieuwsartikel) dynamisch is. Dat maakt het echter ook een leuk voorbeeld, want in live omgevingen heb je misschien ook niet altijd een trainingsdataset om je agents mee te trainen.
6.3 Snelheid
Laten we simpel beginnen. De snelheid waarmee de LLM het antwoord geeft is een makkelijk te meten en kwantificeren KPI, die bovendien al standaard wordt opgeslagen in de basis ell functionaliteit, dus laten we die in ieder geval als metric kiezen
6.4 Inhoudelijke kwaliteit
Deze is lastiger. Elk nieuwsbericht is natuurlijk anders qua inhoud, en we willen niet handmatig alle nieuwsberichten of llm aanroepen doorlopen. Laten we de kwaliteit proberen te bepalen op basis van de volgende factoren:
- semantische overeenkomst van de originele tekst vergeleken met de samenvatting: De mate waarin de samenvatting overeenkomt met de originele tekst is wat mij betreft de belangrijkste metric. We kunnen dit meten door van zowel de vraag als het antwoord een vector embeddings representatie te maken en vervolgens de cosine similarity van de samenvatting met de originele tekst te meten. (Mocht je onbekend zijn met deze termen, dan kun je er hier veel meer over lezen)
- volledigheid: Laten we deze metric bepalen door een LLM simpelweg te laten beoordelen hoe volledig de samenvatting is ten opzichte van de originele tekst. Ik wou niet enkel rusten op de beoordeling van andere LLMs voor de kwaliteit van de output, maar in combinatie met de semantische overeenkomst en met een meer gerichtere vraag om een specifiek aspect van de kwaliteit van de samenvatting te beoordelen zie ik geen bezwaren.
- objectiviteit Ook hier kiezen we voor een score op basis van een LLM beoordeling.
6.5 Database updaten
laten we nu we de metrics weten de database updaten met een nieuwe tabel, waarin we onze evaluaties opslaan:
We voegen een evaluation tabel toe voor evaluaties van invocaties van de LLM. Waarbij 1 invocatie meerdere evaluaties kan hebben (objectiviteit, volledigheid en semantische overeenkomst.
Mocht je meekijken met mijn github code, hier een utility script om de nieuwe tabel aan te makenin de database.
Mocht je de code als package geïnstalleerd hebben, via pip install -e .
, kun je ipv het script handmatig uit te voeren ook de command ell_initialize_db
gebruiken in je terminal (meer info in de github Readme).
6.6 LMP's en overige functies aanmaken
Nu we onze database hebben opgezet, kunnen we beginnen met het creëren van de LMP's (Language Model Programs) en de ondersteunende functies die we nodig hebben voor onze A/B test.
6.7 Nieuwsartikelen ophalen
We beginnen met een eenvoudige functie om nieuwsartikelen op te halen uit de RSS feed van de NOS:
def fetch_news(n: int) -> List[Tuple[str, str]]:
"""Fetch n news articles from the RSS feed."""
feed = feedparser.parse(RSS_FEED_URL)
return [(entry.title, entry.summary) for entry in feed.entries[:n]]
Deze functie gebruikt de feedparser
bibliotheek om de RSS feed te parsen en retourneert een lijst van tuples met de titel en samenvatting van elk artikel.
6.8 LMP's voor nieuwssamenvattingen
Vervolgens definiëren we onze twee LMP's voor het samenvatten van nieuws:
@ell.simple(model="gpt-4-turbo", temperature=0.1)
def summarize_news_v1(title: str, content: str) -> str:
"""Summarize the given news article with a focus on factual reporting."""
return f"""You are an expert news editor known for your ability to distill complex news stories into clear, concise summaries. Your task is to summarize the following news article in 2-3 sentences, focusing on the most crucial facts and key points. Maintain a neutral, objective tone and ensure all information is accurate.
Title: {title}
Content: {content}
Summary (2-3 sentences):"""
@ell.simple(model="gpt-4o-mini", temperature=0.9)
def summarize_news_v2(title: str, content: str) -> str:
"""Summarize the given news article make it funny."""
return f"""You are a gossip news editor known for your ability to entertain. Your task is to summarize the following news article in 2-3 sentences, focusing on the most fun points. Maintain a sensational and engaging tone
Title: {title}
Content: {content}
Summary (2-3 sentences):"""
Merk op dat we hier gebruik maken van de @ell.simple
decorator, die zorgt voor de logging en versioning van onze LMP's.
6.9 Evaluatiefuncties
Voor de evaluatie van onze samenvattingen hebben we twee functies nodig:
- Een functie die de semantische overeenkomst berekent met behulp van vector embeddings:
def evaluate_summary(original: str, summary: str) -> float:
"""Evaluate the summary using vector embeddings and cosine similarity."""
original_embedding = vo.embed([original], model="voyage-3", input_type="document").embeddings[0]
summary_embedding = vo.embed([summary], model="voyage-3", input_type="document").embeddings[0]
similarity = cosine_similarity([original_embedding], [summary_embedding])[0][0]
return similarity
- Een LMP die de volledigheid en objectiviteit van de samenvatting beoordeelt:
@ell.complex(model="gpt-4o-2024-08-06", response_format=SummaryEvaluation)
def evaluate_summary_llm(original: str, summary: str) -> SummaryEvaluation:
"""Evaluate the given summary based on completeness and objectivity."""
return f"""You are an expert in evaluating news summaries. Your task is to assess the following summary based on two criteria: completeness and objectivity.
Completeness measures how well the summary captures all the key points of the original text. A score of 1 means the summary includes all important information, while 0 means it misses crucial points.
Objectivity measures how neutral and unbiased the summary is compared to the original text. A score of 1 means the summary maintains the same level of objectivity as the original, while 0 means it introduces significant bias or opinion.
Please evaluate the summary and provide scores for both criteria as floats between 0 and 1.
Original text:
{original}
Summary:
{summary}
Evaluation:"""
Hier gebruiken we de @ell.complex
decorator omdat we een gestructureerde output verwachten in de vorm van een SummaryEvaluation
object.
6.10 Hulpfuncties
We hebben ook enkele hulpfuncties nodig om onze evaluaties op te slaan en de juiste invocation ID's te verkrijgen:
def save_evaluation(invocation_id: str, metric_name: str, metric_value: float):
"""Save the evaluation result to the database."""
store = ell.get_store()
conn_string = store.engine.url.database
conn = sqlite3.connect(conn_string, detect_types=sqlite3.PARSE_DECLTYPES)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO evaluation (id, invocation_id, metric_name, metric_value, created_at)
VALUES (?, ?, ?, ?, ?)
''', (str(uuid.uuid4()), invocation_id, metric_name, metric_value, datetime.now()))
conn.commit()
conn.close()
def get_invocation_id(summary):
"""Extract the invocation_id from the __origin_trace__ frozenset."""
for item in summary.__origin_trace__:
if item.startswith('invocation-'):
return item
return None
6.11 Hoofdlogica
Nu we alle nodige functies hebben gedefinieerd, kunnen we de hoofdlogica van onze A/B test implementeren in de main()
functie:
def main():
news_articles = fetch_news(4)
for i, (title, content) in enumerate(news_articles, 1):
print(f"\\nProcessing Article {i}:")
original_text = f"{title}\\n{content}"
# Summarize and evaluate with v1
summary1 = summarize_news_v1(title, content)
invocation_id1 = get_invocation_id(summary1)
if invocation_id1:
evaluation1 = evaluate_summary(original_text, summary1)
save_evaluation(invocation_id1, "cosine_similarity", evaluation1)
llm_eval1 = evaluate_summary_llm(original_text, summary1)
parsed_eval1 = llm_eval1.content[0].parsed
save_evaluation(invocation_id1, "completeness", parsed_eval1.completeness)
save_evaluation(invocation_id1, "objectivity", parsed_eval1.objectivity)
else:
print(f"Warning: Could not find invocation_id for summary1 of article {i}")
evaluation1 = None
parsed_eval1 = None
# Summarize and evaluate with v2
summary2 = summarize_news_v2(title, content)
invocation_id2 = get_invocation_id(summary2)
if invocation_id2:
evaluation2 = evaluate_summary(original_text, summary2)
save_evaluation(invocation_id2, "cosine_similarity", evaluation2)
llm_eval2 = evaluate_summary_llm(original_text, summary2)
parsed_eval2 = llm_eval2.content[0].parsed
save_evaluation(invocation_id2, "completeness", parsed_eval2.completeness)
save_evaluation(invocation_id2, "objectivity", parsed_eval2.objectivity)
else:
print(f"Warning: Could not find invocation_id for summary2 of article {i}")
evaluation2 = None
parsed_eval2 = None
# Print results
print(f"Artikel {i}:")
print(f"Titel: {title}")
print(f"Samenvatting 1: {summary1}")
print(f"Evaluatie 1 (Cosine Similarity): {evaluation1}")
print(f"Evaluatie 1 (Completeness): {parsed_eval1.completeness if parsed_eval1 else 'N/A'}")
print(f"Evaluatie 1 (Objectivity): {parsed_eval1.objectivity if parsed_eval1 else 'N/A'}")
print(f"Invocation ID 1: {invocation_id1}")
print(f"Samenvatting 2: {summary2}")
print(f"Evaluatie 2 (Cosine Similarity): {evaluation2}")
print(f"Evaluatie 2 (Completeness): {parsed_eval2.completeness if parsed_eval2 else 'N/A'}")
print(f"Evaluatie 2 (Objectivity): {parsed_eval2.objectivity if parsed_eval2 else 'N/A'}")
print(f"Invocation ID 2: {invocation_id2}\\n")
Deze main()
functie voert de volgende stappen uit voor elk nieuwsartikel:
- Haalt nieuwsartikelen op met
fetch_news()
. - Genereert samenvattingen met beide LMP's (
summarize_news_v1
ensummarize_news_v2
). - Evalueert elke samenvatting op semantische overeenkomst met
evaluate_summary()
. - Beoordeelt elke samenvatting op volledigheid en objectiviteit met
evaluate_summary_llm()
. - Slaat alle evaluatiemetrics op in de database met
save_evaluation()
7. Evaluatie
7.1 check de database
Wanneer we ons script op een x aantal nieuwsartikelen hebben gedraaid kunnen we gaan evaluaren welk prompt het beste scoort op de door ons aangegeven metrics. We hebben alle data netjes opgeslagen in onze database:
7.2 Het maken van een rapportage
We kunnen nu de gemiddelde latency, accuraatheid en objectiviteit visualiseren. Ik wil hier als next step nog een wat mooier interactiever dashboard voor bouwen, maar gezien de lengte van deze blog hou ik het voor nu bij een simpele Matplotlib visualisatie:
Mocht je meekijken met mijn github code, hier een utility script om de gemiddelde scores te visualiseren van elke variant van een LMP, en deze vervolgens in een markdown rapportage op te slaan.
Mocht je de code als package geïnstalleerd hebben, via pip install -e .
, kun je ipv het script handmatig uit te voeren ook de command ell_eval
gebruiken in je terminal (meer info in de github Readme).
Conclusie
Ell is een fijn framework dat veel potentie heeft om een enorm bruikbare tool te worden in de wereld van LLMs en prompt engineering. Het heeft veel goede uitgangspunten en is eenvoudig te begrijpen. Qua tooluse en structured output kan het framework nog wel wat werk gebruiken. Hoe gaan jullie te werk bij het optimaliseren van prompts, en hoe zorg je voor een gestructuereerde manier van testen? ik hoor graag van je als je!