Compiled on: 2026-10-22 — printable version
Validating generative software is the topic of the next lecture
Recall: we assume the reader is familiar with the Chat Completion API, and with the notion of prompt templates from previous lectures
3 candidates, and as many applications, with their passport, transcript of records, and presentation letter (in natural language)
we want to extract structured information from these documents (e.g. name, age, grades, research experience, etc.) to ease evaluation



A prompt is the whole input that is given to an LLM, in order to elicit some desired output
Prompt engineering is the practice of designing, testing, and refining prompts, so that the model’s responses become more accurate, consistent, and useful for the task at hand
A prompt is commonly made of four (optional) elements (cf. Prompt Engineering Guide):
| Practice | Rationale | Example |
|---|---|---|
| Be clear and specific | the model cannot read your mind | “summarise in 3 bullet points, max 15 words each” vs. “summarise” |
| Give a role / context | narrows down the space of plausible answers (personas add little accuracy, cf. Zheng et al., 2024) | “You are assisting the admission committee of …” |
| Use delimiters | separates instructions from data (and mitigates, not prevents, prompt injection) | <letter> ... </letter>, Markdown headings, triple quotes |
| Say what to do, not only what not to do | negative instructions are followed less reliably | “use plain words” vs. “don’t use jargon” |
| Provide examples | showing is more effective than telling | cf. few-shot prompting |
| Specify the output format | downstream code needs to parse the response | cf. structured outputs |
| Let the model think first | complex tasks benefit from intermediate steps | cf. chain-of-thought and reasoning models |
| Iterate and measure | prompts behave differently across models and versions | keep a set of test inputs with expected outputs |
ChatPromptTemplate.from_template(Path("prompts/scoring.md").read_text())production), and links to evaluations and traces:
TL;DR: forcing the model to produce output in a specific format (e.g. JSON matching a given schema) so that to bring unstructured input data (e.g. natural language) into structured form that can be easily processed by downstream applications (e.g. databases, APIs, etc.)
In the prompt: as a natural language description (“a JSON object with fields name, age, email”), as an example ({"name": "John Doe", "age": 30, "email": "john.doe@example.com"}), or as a formal schema (e.g. JSON Schema), possibly combined
In strongly-typed programming languages (e.g. Java), client libraries may reverse-engineer the schema from the type definitions in the code, using reflection, or leverage serialization libraries such as Jackson
In Python, one can use libraries like pydantic to define data models with validation logic, and then generate JSON Schemas from them
openai) often have built-in support for pydantic modelspydantic data models are Python classes extending pydantic.BaseModel, with type annotations for the fields (also from the standard library, e.g. datetime, Optional), possibly nested
To make the data model understandable to the LLM, one should add documentation strings (docstrings and Field(description=...)), which end up in the generated JSON Schema as description fields
from typing import Optional
from pydantic import BaseModel, Field # <-- notice the import of Field for adding documentation strings
from datetime import datetime
class User(BaseModel):
"""Anagraphical and contact information about a user."""
name: str = Field(..., description="The user's full name")
age: int = Field(..., description="The user's age in years")
email: Optional[str] = Field(default=None, description="The user's email address")
class Post(BaseModel):
"""A blog post created by a user."""
title: str = Field(..., description="The title of the post")
content: str = Field(..., description="The content of the post")
author: User = Field(..., description="The author of the post")
created_at: datetime = Field(default_factory=datetime.now, description="The date and time when the post was created")
Field(...): the ... (Ellipsis) as first argument marks the field as required, while default= or default_factory= (a function computing the default) make it optionalpydantic data model $\approx$ data classes with validation and serialization logic: e.g. User(name="John Doe", age=30) (type-checked constructor), user.model_dump_json(), User.model_json_schema()For instance, User.model_json_schema() produces the following JSON Schema (formatted in YAML for better readability):
title: User
type: object
description: Anagraphical and contact information about a user.
properties:
name: {title: Name, type: string, description: The user's full name}
age: {title: Age, type: integer, description: The user's age in years}
email:
anyOf: [{type: string}, {type: 'null'}]
default: null
title: Email
description: The user's email address
required: [name, age]
required (and no defaults): client libraries (e.g. openai’s .parse(...)) rewrite the schema accordingly before sending it
Optional[...] without default, i.e. the field must be present, but may be null (as in Example 1, below)From weakest to strongest guarantees:
Prompt-only: describe the format in the prompt, then parse (and validate) the response
JSON mode (e.g. response_format={"type": "json_object"}): the output is guaranteed to be valid JSON, but not to match your schema
Schema-constrained output (e.g. response_format={"type": "json_schema", ..., "strict": true}): the output is guaranteed to match the JSON Schema
Tool-calling trick: declare a (fake) tool whose parameters are the target schema, and force the model to call it
| Provider / tool | How to request schema-constrained output | Docs |
|---|---|---|
| OpenAI (Chat Completion) | response_format={"type": "json_schema", "json_schema": {..., "strict": True}}, or client.chat.completions.parse(response_format=PydanticClass) |
link |
| OpenAI-compatible (e.g. OpenRouter) | same as OpenAI, if the underlying model supports it | link |
| Anthropic (Messages) | output_config={"format": {"type": "json_schema", "schema": ...}}, or strict tool use |
link |
| Google Gemini | JSON mime type + JSON Schema in the request configuration | link |
| Ollama (local) | format=<JSON Schema> in the request |
link |
| LangChain (any of the above) | llm.with_structured_output(PydanticClass) |
link |
pydantic classes directly, and convert them into JSON Schemas for youjson_schema support? Errors, or schema silently ignored: in LangChain, use method="function_calling" (tool-calling trick) or "json_mode"; on OpenRouter, set provider.require_parameters=true[BEWARE] the format is guaranteed to be correct, the content is not: always validate the values
Goal: let’s extract structured information from the candidates’ presentation letters
Let’s import openai and initialize the client as usual:
import os
from openai import OpenAI
base_url = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1/")
api_key = os.environ.get("OPENAI_API_KEY") or input(f"Enter your API key for {base_url}: ")
model = os.environ.get("OPENAI_MODEL", "nvidia/nemotron-3-super-120b-a12b:free")
client = OpenAI(base_url=base_url, api_key=api_key)
Let’s design a system prompt to contain general instructions for the model
general_instructions = """
You are assisting the admission committee of University of Bologna PhD programmes.
Your goal is to help assess recommendation letters for PhD applications.
You'll be provided with the text of each letter,
and you'll have to analyse it to extract structured information in JSON.
"""
Let’s define pydantic classes to represent the target format, with documentation strings to complement the specification
Imports:
from typing import Optional
from pydantic import BaseModel, Field
A class for the applicant’s information:
class ApplicantInfo(BaseModel):
"""Structured information about the applicant, extracted from the letter."""
name: str = Field(description="Full name of the applicant, first name first, family name last.")
degree: Optional[str] = Field(description="Degree obtained by the applicant before the application, according to the letter.")
alma_mater: Optional[str] = Field(description="University where the applicant obtained their degree, according to the letter.")
attended: list[str] = Field(description="List of courses or teachings the applicant has attended, as mentioned in the letter.")
skills: list[str] = Field(description="List of skills possessed by the applicant, as mentioned in the letter.")
strengths: list[str] = Field(description="List of strengths identified for the applicant, as mentioned in the letter, except skills.")
weaknesses: list[str] = Field(description="List of weaknesses identified for the applicant, as mentioned in the letter.")
A class for the author’s information:
class AuthorInfo(BaseModel):
"""Structured information about the author of the letter, extracted from the letter."""
name: str = Field(description="Full name of the author, first name first, family name last.")
affiliation: str = Field(description="Affiliation of the author, according to the letter.")
position: Optional[str] = Field(description="Position of the author, according to the letter, in their institution.")
seniority: Optional[int] = Field(description="Seniority of the author: 1=researcher or lecturer, 2=assistant professor, 3=associate professor, 4=full professor, 0=other. " \
"Scale similarly in case of non-academic positions, e.g., 1=entry-level, 2=mid-level, 3=senior, 4=executive.")
email: str = Field(description="Email of the author, according to the letter.")
nationality: Optional[str] = Field(description="Nationality of the author, according to the letter.")
relationship_with_applicant: Optional[str] = Field(description="Relationship of the author with the applicant, according to the letter.")
A class for the overall scoring of the letter, including the extracted information and the final score:
class LetterInfo(BaseModel):
applicant: ApplicantInfo = Field(description="Information about the person the letter is about, i.e., the one applying for the PhD programme.")
author: AuthorInfo = Field(description="Information about the person who wrote the letter, i.e., the one recommending the applicant for the PhD programme.")
application_for: str = Field(description="The PhD programme for which the applicant is applying.")
score: int = Field(description=f"The score assigned to the letter according to the following rules.{scoring_instructions}")
Instructions for scoring can be provided as well in natural language, yet better to be precise and give the LLM a “recipe” for scoring
scoring_instructions = """
Maximum score is +5, minimum score is 0.
Start by maximum and decrease by 1 for each issue among the following:
- -1 per each lacking non-optional field,
- not clear how the author knows the applicant,
- vague relationship between the author and the applicant: no storytelling about the applicant,
- skills mentioned in the letter are very generic and not supported by concrete examples,
- no strengths mentioned in the letter or very generic ones, not supported by concrete examples,
- author mentions weaknesses about the applicant explicitly or implicitly,
- letter is not tailored for a specific PhD programme.
"""
With these ingredients in mind, the automatic scoring logic is as simple as a single request–response interaction with the LLM:
def score_letter(letter_text: str, client: OpenAI = client, model: str = model) -> LetterInfo:
# .parse(...) passes the JSON Schema of LetterInfo to the model, and parses the answer back into a LetterInfo
response = client.chat.completions.parse(
model=model,
messages=[
dict(role="system", content=general_instructions),
dict(role="user", content=f"Score the following letter:\n\n<letter>\n{letter_text}\n</letter>"),
],
response_format=LetterInfo,
)
message = response.choices[0].message
if message.refusal: # the model may refuse to answer, e.g. for safety reasons
raise RuntimeError(f"Model refused: {message.refusal}")
return message.parsed
notice that:
client.chat.completions.parse(...) is called in place of .create(...)
LetterInforesponse_format parameter is set to a reference to the LetterInfo class
openai will automatically generate the corresponding JSON Schema from the class definition, and include it in the requestmessage.parsed is the LetterInfo instance, unless the model refuses to answer (e.g. for safety reasons): then message.refusal holds its explanationFull code here
At this point, the logic of the program is trivial (load letter file $\rightarrow$ call score_letter(...) $\rightarrow$ print the result):
if __name__ == "__main__":
import sys
import data
print(f"Using model: {model}")
letter_path = sys.argv[1] if len(sys.argv) > 1 else input("Enter the letter to score (candidate's ID or path): ")
letter_path = data.find_letter(letter_path)
if not letter_path.is_file():
print(f"File {letter_path} is not a file.")
sys.exit(1)
letter_text = letter_path.read_text()
letter_info = score_letter(letter_text)
print(letter_info.model_dump_json(indent=2)) # a LetterInfo object, printed as JSON
data.find_letter(...) (from the data package of lab-snippets): the Path of a letter, given either a candidate’s ID (e.g. mario-rossi) or a file pathapplicant:
name: Mario Rossi
degree: Master's degree in Computer Science and Engineering
alma_mater:
attended: [ "Machine Learning and Data Mining" ]
skills:
- algorithms
- probability
- statistics
- machine learning
- database systems
- programming
- experimental evaluation
- designing reproducible computational experiments
strengths:
- strong analytical ability
- intellectual discipline
- capacity to connect theoretical material with implementation
- independence
- reliability in deadlines
- clear communication
- constructive and precise in group settings
- methodical and capable of sustained work on open-ended problems
weaknesses: []
author:
name: Alessandro Bianchi
affiliation: Department of Computer Science and Engineering, University of Bologna
position: Associate Professor of Computer Science
seniority: 3
email: alessandro.bianchi@example.edu
nationality:
relationship_with_applicant: Course instructor and thesis supervisor
application_for: PhD Programme in Data Science
score: 5
applicant:
name: Jean Dupont
degree:
alma_mater: Université de Lyon
attended:
- Apprentissage automatique
- Vision par ordinateur
skills:
- Machine learning
- Computer vision
- Supervised learning
- Classification methods
- Dimensionality reduction
- Support Vector Machines (SVM)
- Data analysis
- Programming
strengths:
- Technical competence in AI/ML
- Ability to implement algorithms and follow protocols
- Dependable and focused in structured settings
- Receptive to feedback and able to work with explicit milestones
weaknesses:
- Prefers well-defined problems and structured supervision; less comfortable defining
problems independently
- Written work sometimes descriptive rather than analytical
- May not quickly identify deeper limitations or broader implications
author:
name: Claire Moreau
affiliation: Université de Lyon, Département d’Informatique
position: Maîtresse de conférences en informatique
seniority: 1
email: claire.moreau@example.fr
nationality:
relationship_with_applicant: Course instructor for Jean Dupont’s Master’s courses
(Apprentissage automatique) and his primary contact during the research-oriented
module Vision par ordinateur
application_for: PhD Programme in Artificial Intelligence
score: 4
applicant:
name: Mohammed Ali
degree:
alma_mater:
attended:
- computing
- software development
- information systems
- programming
- software engineering
- databases
- computer networks
- Islamic studies
- ethics
- communication
- social responsibility
skills:
- programming
- software engineering
- databases
- computer networks
strengths:
- polite
- serious
- disciplined
- ability to follow academic instructions
weaknesses: []
author:
name: Kareem Al-Haddad
affiliation: Department of Computer Science, University of Amman
position: Associate Professor
seniority: 3
email: k.alhaddad@example.edu.jo
nationality:
relationship_with_applicant: course instructor in Software Engineering programme
application_for: PhD Programme in Cyber Security
score: 4
Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ └── example1/
│ └── letter_scoring_openai.py # the letter-scoring system
└── pyproject.toml # dependencies of all snippetspoetry run python -m snippets -l prompting -e 1 data/letter-mario-rossi.txt # or just: mario-rossi
OPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL), cf. Free Access to LLMsLangChain is a third-party, open-source framework for building LLM-based applications (docs)
It provides a uniform meta-model on top of many providers’ APIs:
ChatOpenAI, ChatAnthropic, ChatGoogleGenerativeAI, ChatOllama): one class per provider, same interface
pip install langchain-openai)SystemMessage, HumanMessage, AIMessage, …), prompt templates, output parsers, tools, ….invoke(input), .batch(inputs), .stream(input) (and async variants), which can be composed with the | operatorWhy bother?
Why not?
extra_body)Let’s import LangChain’s chat model for OpenAI-compatible APIs, and initialize it, reusing the configuration, the instructions, and the pydantic classes of Example 1 (i.e. the same environment variables):
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
# same configuration, instructions, and output schema (Pydantic classes) as in Example 1
from snippets.lecture_prompting.example1.letter_scoring_openai import base_url, api_key, model, general_instructions, \
ApplicantInfo, AuthorInfo, LetterInfo
llm = ChatOpenAI(base_url=base_url, api_key=api_key, model=model)
ChatOpenAI (package langchain_openai) wraps the openai client: same base_url and api_key, but the model is set once, at construction timeLet’s define the prompt as a template, with a named placeholder ({letter_text}) for the input data:
# a prompt template: messages with placeholders (e.g. {letter_text}), filled in at invocation time
prompt = ChatPromptTemplate.from_messages([
("system", general_instructions),
("user", "Score the following letter:\n\n<letter>\n{letter_text}\n</letter>"),
])
ChatPromptTemplate.from_messages accepts a list of (role, template) pairsprompt.invoke({"letter_text": "..."}) would produce the list of messages to be sent to the modelpydantic classes and scoring instructions are imported from the OpenAI version, as they do not depend on the client library (full code here)
The scoring logic is a chain: prompt template $\rightarrow$ chat model constrained to produce LetterInfo instances:
# a chain: the filled-in prompt goes to the LLM, whose answer is parsed into a LetterInfo
scoring_chain = prompt | llm.with_structured_output(LetterInfo)
def score_letter(letter_text: str) -> LetterInfo:
return scoring_chain.invoke({"letter_text": letter_text}) # fills the {letter_text} placeholder
notice that:
llm.with_structured_output(LetterInfo) returns a new runnable, whose output is an instance of LetterInfo
method argument selects how structure is enforced: "json_schema" (default for OpenAI), "function_calling" (tool-calling trick), or "json_mode"include_raw=True returns the raw response too (useful to inspect token usage, or parsing errors)prompt | ... creates a sequence: the output of the template (messages) is the input of the modelscoring_chain.invoke({...}) runs the whole chain, scoring_chain.batch([{...}, {...}]) runs it on many inputs, in parallelThe main part of the program is unchanged:
poetry run python -m snippets -l prompting -e 1bis data/letter-mario-rossi.txt
Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ └── example1bis/
│ └── letter_scoring_langchain.py # the letter-scoring system
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL), cf. Free Access to LLMs| Aspect | openai client |
LangChain |
|---|---|---|
| Model access | OpenAI(base_url, api_key) + model= at each request |
ChatOpenAI(base_url, api_key, model), once |
| Prompt | list of dict(role=..., content=...), built with f-strings |
ChatPromptTemplate with named placeholders |
| Structured output | client.chat.completions.parse(response_format=Class) |
llm.with_structured_output(Class) |
| Result | response.choices[0].message.parsed (check .refusal!) |
the Class instance, directly |
| Composition | plain Python functions | runnables composed with | |
| Batching / parallelism | up to you (e.g. asyncio) |
.batch(...), .abatch(...) |
| Other providers | only OpenAI-compatible APIs | any provider with a LangChain integration |
| Access to provider-specific features | immediate | may require extra_body=... or model_kwargs=... |
pydantic classes) are the same: only the syntax changesProblem: the scoring recipe provided to the model is still quite vague and unstructured, which may lead to inconsistent and non-reproducible scores. Even when good, scores are not explainable, as the reasoning behind them is not made explicit.
Code: put your solution in
snippets/lecture_prompting/exercise1/oflab-snippets, and run it viapoetry run python -m snippets -l prompting -x 1
Solution: a walkthrough follows in the next (vertical) column — try on your own first!
⚠️ Spoiler alert: the walkthrough of the solution of Exercise 1 is about to start
master branch of lab-snippets
exercises branch, which only contains placeholdersSteps 2–3: the scoring recipe becomes a checklist, i.e. a Pydantic class with one boolean field per criterion
# step 2-3: the scoring recipe of Example 1 bis, turned into a checklist, i.e. one boolean field per criterion
# (phrased so that True is always GOOD, and descriptions tell the LLM exactly what to check)
class Checklist(BaseModel):
"""Criteria for assessing a recommendation letter: set each one to true only if the letter clearly satisfies it."""
clear_acquaintance: bool = Field(description="The letter clearly states how, where, and for how long the author knows the applicant.")
storytelling: bool = Field(description="The letter tells concrete stories or episodes about the applicant, rather than just listing adjectives.")
concrete_skills: bool = Field(description="The skills attributed to the applicant are specific and supported by concrete examples.")
concrete_strengths: bool = Field(description="The letter mentions strengths of the applicant (other than skills), supported by concrete examples.")
no_weaknesses: bool = Field(description="The author does NOT mention weaknesses of the applicant, neither explicitly nor implicitly (e.g. 'needs supervision').")
tailored: bool = Field(description="The letter is tailored for the specific PhD programme the applicant is applying for.")
True is always good: the score is then just a count of failures
has_weaknesses field) is an easy source of bugs in the scoring codedescription tells the LLM exactly what to check: it ends up in the JSON Schema, hence in the promptStep 4: the LLM fills in the checklist, but no longer outputs the score
class LetterInfo(BaseModel):
applicant: ApplicantInfo = Field(description="Information about the person the letter is about, i.e., the one applying for the PhD programme.")
author: AuthorInfo = Field(description="Information about the person who wrote the letter, i.e., the one recommending the applicant for the PhD programme.")
application_for: str = Field(description="The PhD programme for which the applicant is applying.")
checklist: Checklist = Field(description="Assessment of the letter, criterion by criterion.")
# no `score` field here: the LLM does not compute it anymore!
scoring_chain = prompt | llm.with_structured_output(LetterInfo) # step 4: the LLM fills in the checklist
def score_letter(letter_text: str) -> LetterInfo:
return scoring_chain.invoke({"letter_text": letter_text})
ApplicantInfo, and AuthorInfo are imported from Example 1 (bis): only the scoring changesprompt | llm.with_structured_output(...) as before: only the target class differsStep 5: the score is computed by code, from the checklist, as
@propertys of the Pydantic class
# step 5: the scoring algorithm, as code (a property: computed from the other fields, never generated by the LLM)
@property
def missing_fields(self) -> list[str]:
"""The non-optional fields which the letter does not provide (e.g. empty strings or lists)."""
required = {
"applicant.name": self.applicant.name, "applicant.skills": self.applicant.skills, "applicant.strengths": self.applicant.strengths,
"author.name": self.author.name, "author.affiliation": self.author.affiliation, "author.email": self.author.email,
"application_for": self.application_for,
}
return [name for name, value in required.items() if not value]
@property
def penalties(self) -> list[str]:
"""Why points were lost: the explanation of the score."""
failed = [name for name, satisfied in self.checklist.model_dump().items() if not satisfied]
return failed + [f"missing {name}" for name in self.missing_fields]
@property
def score(self) -> int:
"""Starts from the maximum (5), and decreases by 1 per penalty, down to the minimum (0)."""
return max(0, 5 - len(self.penalties))
model_dump() turns a Pydantic object into a dict (field name $\rightarrow$ value): here, to iterate over the criteriapenalties is the explanation of the score: which criteria failed, which fields are missingFiles of this solution, in the lab-snippets repository (branch master):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ ├── example1bis/
│ │ └── letter_scoring_langchain.py # LLM, prompt, info classes (reused)
│ └── exercise1/
│ └── letter_scoring_checklist.py # the checklist-based scoring
└── pyproject.toml # dependencies of all snippetspoetry run python -m snippets -l prompting -x 1 mario-rossi # or a path, e.g. data/letter-mario-rossi.txt
OPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL), cf. Free Access to LLMs| Technique | Idea | Reference |
|---|---|---|
| Zero-shot | only instructions, no examples | Radford et al. (2019) |
| Few-shot (a.k.a. in-context learning) | instructions + a few solved examples (input $\rightarrow$ expected output) | Brown et al. (2020) |
| Chain-of-Thought (CoT) | ask the model to write intermediate reasoning steps before the answer | Wei et al. (2022) |
| Zero-shot CoT | just add “Let’s think step by step” | Kojima et al. (2022) |
| Self-consistency | sample many answers (with temperature > 0), then take a majority vote | Wang et al. (2023) |
| Prompt chaining | split a complex task into a sequence of simpler prompts, each feeding the next | Wu et al. (2022) |
| Technique | Raw client library | LangChain |
|---|---|---|
| Zero-shot | list of messages | ChatPromptTemplate.from_messages([...]) |
| Few-shot | append example (user, assistant) pairs by hand | FewShotChatMessagePromptTemplate, optionally with an example selector (e.g. by semantic similarity) |
| CoT | add “think step by step”, or a reasoning field in the output schema |
same, with with_structured_output(...) |
| Self-consistency | $N$ requests (e.g. via asyncio), then vote |
chain.batch([input] * N), then vote |
| Prompt chaining | call the model, parse, build next prompt, call again | compose runnables with | (e.g. prompt1 | llm | parser | prompt2 | llm) |
Goal: classify the tone of each presentation letter as
enthusiastic,lukewarm, orcritical, and compare techniques
Let’s initialize the chat model (notice temperature=1.0: we want variability, for self-consistency):
base_url = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1/")
api_key = os.environ.get("OPENAI_API_KEY") or input(f"Enter your API key for {base_url}: ")
model = os.environ.get("OPENAI_MODEL", "nvidia/nemotron-3-super-120b-a12b:free")
llm = ChatOpenAI(base_url=base_url, api_key=api_key, model=model, temperature=1.0) # some randomness, for self-consistency
The system prompt defines the labels; the output is constrained to them via a Literal type:
Tone = Literal["enthusiastic", "lukewarm", "critical"] # the only admissible labels
system = ("system", """You classify the tone of recommendation letters for PhD applications.
Use exactly one label among: enthusiastic, lukewarm, critical.
- enthusiastic: strong, specific, and unreserved support
- lukewarm: positive, but generic or shallow, with little evidence
- critical: explicit or implicit reservations about the applicant""")
user = ("user", "<letter>\n{letter}\n</letter>")
# 1. zero-shot: instructions only
zero_shot = ChatPromptTemplate.from_messages([system, user])
Literal[...] (from typing): a type admitting only the listed values, which becomes an enum in the JSON SchemaThe few-shot prompt adds solved examples, rendered as past turns of the conversation (user asks, assistant answers):
# 2. few-shot: instructions + solved examples, rendered as past conversation turns
examples = [
{"letter": "Anna led our lab's move to Rust, and her thesis on lock-free queues became a paper at a top venue. "
"She is the best student I supervised in ten years.", "label": "enthusiastic"},
{"letter": "Luca attended my course on distributed systems and passed the exam. "
"He is a hard-working and motivated student, and I recommend him.", "label": "lukewarm"},
{"letter": "Sara is smart, although she sometimes needed reminders to meet deadlines. "
"With proper supervision, she could do well in a PhD.", "label": "critical"},
]
few_shot = ChatPromptTemplate.from_messages([
system,
FewShotChatMessagePromptTemplate(
examples=examples,
example_prompt=ChatPromptTemplate.from_messages([user, ("ai", "{label}")]),
),
user,
])
FewShotChatMessagePromptTemplate fills example_prompt with each dict in examples, producing a (user, ai) message pair per example ("ai" is LangChain’s name for the assistant role)Chain-of-thought via structured output: a reasoning field is placed before the label field:
class ToneOnly(BaseModel):
label: Tone = Field(description="The tone of the letter")
# 3. chain-of-thought: the model must write its reasoning *before* the label (fields are generated in order)
class ToneWithReasoning(BaseModel):
reasoning: str = Field(description="Step by step: quote the key sentences of the letter, and discuss what they reveal about the author's opinion of the applicant")
label: Tone = Field(description="The tone of the letter, decided according to the reasoning above")
# one chain per technique: prompt template | LLM with structured output
chains = {
"zero-shot": zero_shot | llm.with_structured_output(ToneOnly),
"few-shot": few_shot | llm.with_structured_output(ToneOnly),
"cot": zero_shot | llm.with_structured_output(ToneWithReasoning),
}
reasoning after label would make it a mere post-hoc justificationSelf-consistency: run the same chain $N$ times, in parallel, and take the majority vote:
# 4. self-consistency: sample several answers (in parallel), then take a majority vote
def classify(letter: str, technique: str, samples: int = 1) -> tuple[Tone, Counter]:
answers = chains[technique].batch([{"letter": letter}] * samples)
votes = Counter(answer.label for answer in answers)
return votes.most_common(1)[0][0], votes
Counter (from collections) counts the occurrences of each label; .most_common(1)[0][0] is the most voted oneLet’s try it (full code here):
poetry run python -m snippets -l prompting -e 2 zero-shot data/letter-jean-dupont.txt
poetry run python -m snippets -l prompting -e 2 few-shot data/letter-jean-dupont.txt
poetry run python -m snippets -l prompting -e 2 cot data/letter-jean-dupont.txt 5 # CoT + self-consistency, 5 samples
Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ └── example2/
│ └── letter_tone.py # tone classification
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL), cf. Free Access to LLMs| Technique | Extra tokens per request | Extra requests | Best for | Watch out for |
|---|---|---|---|---|
| Zero-shot | none | none | simple, well-specified tasks; strong models | ambiguous label definitions |
| Few-shot | examples (input side) | none | tasks where the format or the borderline cases are hard to describe | biased or unrepresentative examples |
| CoT | reasoning (output side, pricier) | none | multi-step tasks (maths, logic, multi-criteria judgments) | longer latency; reasoning may be unfaithful |
| Self-consistency | $\times N$ | $N - 1$ | tasks with a single correct answer, when reliability matters | cost; open-ended answers cannot be voted |
| Prompt chaining | depends | one per step | complex tasks with separable steps | error propagation across steps |
Reasoning models (a.k.a. thinking models) are LLMs trained to produce a (possibly long) chain of thought before the final answer, without being asked to
gpt-ossThe chain of thought is made of reasoning tokens:
Most APIs let you control how much the model thinks, via a reasoning effort level (e.g. low / medium / high), or a token budget
| Provider | How to control reasoning | Where reasoning tokens are counted | Docs |
|---|---|---|---|
| OpenAI | reasoning_effort (Chat Completion), reasoning={"effort": ...} (Responses) |
usage.completion_tokens_details.reasoning_tokens |
link |
| Anthropic | output_config={"effort": ...}; thinking={...} to enable/disable thinking, or set a budget (older models) |
usage.output_tokens (thinking included) |
link |
| Google Gemini | thinking_level (Gemini 3+), thinking_budget (Gemini 2.5) |
usage_metadata.thoughts_token_count |
link |
| OpenRouter | unified reasoning={"effort": ...} or reasoning={"max_tokens": ...}, mapped onto each provider |
usage.completion_tokens_details.reasoning_tokens |
link |
| Ollama (local) | think=True / False (or a level, for some models) |
— | link |
| LangChain | provider-specific arguments (e.g. ChatOpenAI(reasoning_effort=...)), or extra_body={...} |
response.usage_metadata["output_token_details"]["reasoning"] |
link |
minimal, none, xhigh, max)Goal: rank the three candidates according to their letters, at different reasoning efforts, and compare
Let’s pick a reasoning model by default (e.g. nvidia/nemotron-3-super-120b-a12b:free), and write the question as a template:
base_url = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1/")
api_key = os.environ.get("OPENAI_API_KEY") or input(f"Enter your API key for {base_url}: ")
model = os.environ.get("OPENAI_MODEL", "nvidia/nemotron-3-super-120b-a12b:free") # must be a reasoning model
question = """Here are the presentation letters of three candidates to a PhD programme.
Rank the candidates from the most to the least recommended, in one line per candidate, with a one-sentence justification.
{letters}"""
For each effort level, let’s create a chat model passing OpenRouter’s reasoning parameter via extra_body, then measure time and tokens:
def ask(prompt: str, effort: str) -> None:
# OpenRouter's unified syntax; on OpenAI's own API use ChatOpenAI(..., reasoning_effort=effort) instead
llm = ChatOpenAI(base_url=base_url, api_key=api_key, model=model, extra_body={"reasoning": {"effort": effort}})
start = time.perf_counter()
response = llm.invoke(prompt)
elapsed = time.perf_counter() - start
usage = response.usage_metadata # token counts, as reported by the provider
reasoning_tokens = usage.get("output_token_details", {}).get("reasoning", 0) # hidden "thinking" tokens: paid, not shown
print(f"## effort={effort}: {elapsed:.1f}s, {usage['output_tokens']} output tokens, of which {reasoning_tokens} for reasoning")
print(response.content, end="\n\n")
llm.invoke(...) also accepts a plain string (as a single user message), and returns an AIMessage, whose .content is the answer’s textextra_body is LangChain’s escape hatch to send provider-specific parameters, not (yet) modelled by ChatOpenAIusage_metadata is LangChain’s provider-agnostic view of token usageLet’s run it on all letters (full code here):
if __name__ == "__main__":
import sys
import data
paths = [data.find_letter(arg) for arg in sys.argv[1:]] or data.letters()
letters = "\n\n".join(f"<letter>\n{path.read_text()}\n</letter>" for path in paths)
print(f"Using model: {model}")
for effort in ["low", "medium", "high"]: # same question, increasing effort
ask(question.format(letters=letters), effort)
data.letters(): the paths of all candidates’ letters, used when no letter is given on the command linepoetry run python -m snippets -l prompting -e 3 data/letter-*.txt
Things to observe:
OPENAI_MODEL accordingly)?Files of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── data/
│ ├── letter-jean-dupont.txt # inputs (running example)
│ ├── letter-mario-rossi.txt
│ ├── letter-mohammed-ali.txt
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ └── example3/
│ └── reasoning_effort.py # ranking at several efforts
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL), cf. Free Access to LLMsWhen: multi-step problems (maths, code, planning, multi-criteria decisions), where a wrong answer costs more than a slow one
When not: simple extraction, classification, or chit-chat, where latency and cost matter more
How to prompt them (cf. OpenAI, Anthropic guides):
max_tokens: it bounds reasoning + answer togetherReasoning traces are not faithful explanations of how the answer was computed (cf. Chen et al. (2025)): do not use them as justifications for decisions about people
The context window is the maximum number of tokens a model can process in a single request: input (all messages) + output (including reasoning tokens)
Recall: the Chat Completion API is stateless, so the client re-sends the whole history at each turn
Even within the window, more context is not necessarily better:
Context management (a.k.a. context engineering, cf. Anthropic) is the practice of deciding what enters the context, when, and in which order:
Prompt caching: the provider stores the internal state of the model (the KV cache) for a prefix of the prompt, and reuses it when a later request starts with exactly the same prefix
Consequences for prompt design:
Not to be confused with the client-side caching of Exercise 1 of the LLM-as-a-Service lecture:
| Client-side response caching | Provider-side prompt caching | |
|---|---|---|
| What is cached | the whole response, for an identical request | the model’s state for a prefix of the prompt |
| Is the model called? | no | yes, the response is always generated anew |
| Saves | 100% of cost and latency | part of input cost, and time-to-first-token |
| Changes the output? | yes: same output every time (no sampling variability) | no |
| Provider | Token counting | Prompt caching | Server-side compaction | Docs |
|---|---|---|---|---|
| OpenAI | POST /v1/responses/input_tokens (client.responses.input_tokens.count(...)), or offline via tiktoken (text only), cf. guide |
automatic for long prompts; optional prompt_cache_key |
context_management in the Responses API, or /responses/compact |
caching, compaction |
| Anthropic | /v1/messages/count_tokens endpoint |
cache_control: automatic (top-level) or explicit breakpoints; cache writes cost extra |
beta: on demand, or at a token threshold; plus context editing | caching, compaction |
| Google Gemini | count_tokens method |
implicit (automatic), or explicit caches created in advance | — | caching, tokens |
| OpenRouter | reported in usage |
forwarded to the underlying provider | — | caching |
| LangChain | count_tokens_approximately(...), llm.get_num_tokens_from_messages(...) (model-specific) |
reported in usage_metadata["input_token_details"]["cache_read"] |
trim_messages(...), summarisation middleware for agents |
short-term memory |
Goal: a CLI chat (as in Example 1 of the LLM-as-a-Service lecture) which keeps the context within a given token budget, by summarising older messages (best effort: the last
keep_lastmessages are never dropped)
Let’s configure the chat model, the budget, and how many recent messages to keep verbatim:
base_url = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1/")
api_key = os.environ.get("OPENAI_API_KEY") or input(f"Enter your API key for {base_url}: ")
model = os.environ.get("OPENAI_MODEL", "nvidia/nemotron-3-super-120b-a12b:free")
budget = int(os.environ.get("CONTEXT_BUDGET", "1000")) # tokens; tiny on purpose, to see compaction happen
keep_last = 4 # most recent messages, always kept verbatim
llm = ChatOpenAI(base_url=base_url, api_key=api_key, model=model)
instructions = os.environ.get("SYSTEM_PROMPT", "You are a helpful assistant. Be concise.")
summary = "" # summary of the older part of the conversation (initially empty)
history: list[BaseMessage] = []
summary, plus the recent history
BaseMessage: LangChain’s common superclass of SystemMessage, HumanMessage, and AIMessageCONTEXT_BUDGET environment variable (in tokens), the instructions from SYSTEM_PROMPT (optional)The context sent at each request is: system instructions + summary (if any) + recent history:
def prefix() -> list[BaseMessage]:
# the fixed instructions first (a stable prefix, hence cacheable), then the summary (if any)
return [SystemMessage(instructions)] + ([SystemMessage(f"Summary of the earlier conversation:\n{summary}")] if summary else [])
def context() -> list[BaseMessage]:
return [*prefix(), *history] # what is actually sent to the LLM
Compaction is itself an LLM call, asking to summarise the older messages (and the previous summary):
def compact() -> None:
global summary, history
older, history = history[:-keep_last], history[-keep_last:]
# the LLM itself summarises the older messages (and the previous summary, if any)
request = [*prefix(), *older, HumanMessage("Summarise the conversation so far in a few sentences. "
"Keep all facts, names, numbers, and decisions.")]
summary = llm.invoke(request).content
The main loop compacts the context whenever its (approximate) size exceeds the budget, before sending it, and prints token usage at each turn:
while True:
try:
history.append(HumanMessage(input("You: ")))
except (EOFError, KeyboardInterrupt):
break
if count_tokens_approximately(context()) > budget and len(history) > keep_last: # over budget: compact, then send
compact()
print(f" [context compacted: now ~{count_tokens_approximately(context())} tokens]")
response = llm.invoke(context())
history.append(response)
print(f"AI: {response.content}")
usage = response.usage_metadata
cached = usage.get("input_token_details", {}).get("cache_read", 0) # prompt caching: a stable prefix is cheaper
print(f" [input: {usage['input_tokens']} tokens, of which {cached} cached | output: {usage['output_tokens']} tokens]")
count_tokens_approximately is provider-agnostic (it assumes $\approx$ 4 characters per token): good enough for budgeting, not for billingusage_metadata reports the actual counts, including cached input tokens (if the provider supports caching)Let’s try it (full code here):
poetry run python -m snippets -l prompting -e 4
Things to observe:
SYSTEM_PROMPT) and a provider supporting caching, how many input tokens are cached? What happens to them right after a compaction?Possible improvements (left to the reader):
trim_messages, and compare the two strategiesFiles of this example, in the lab-snippets repository (cf. how to set it up, and run snippets):
lab-snippets/
├── snippets/
│ └── lecture_prompting/
│ └── example4/
│ └── chat_with_compaction.py # the CLI chat
└── pyproject.toml # dependencies of all snippetsOPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, OPENAI_MODEL, CONTEXT_BUDGET), cf. Free Access to LLMsGoal: let’s say the committee wants to extract structured information from the ID documents of the candidates, which are provided as pictures in the application form, and wants to be confident about the extracted values
Code: put your solution in
snippets/lecture_prompting/exercise2/oflab-snippets, and run it viapoetry run python -m snippets -l prompting -x 2
google/gemma-4-26b-a4b-it:free)Many ways, documented here, simplest one is passing a base64-encoded image in the content of a message
content is now a list of typed pieces (text, image, …){"type": "image_url", "image_url": {"url": "data:..."}})[HOW-TO] Constructing a message with both text and image content:
import base64
from langchain_core.messages import HumanMessage
def message_with_image(text, image_path, mime_type="image/png"):
with open(image_path, "rb") as image_file:
data = base64.b64encode(image_file.read()).decode("utf-8")
return HumanMessage(content=[
{"type": "text", "text": text},
{"type": "image", "base64": data, "mime_type": mime_type},
])
[HOW-TO] Define the Pydantic class to represent the expected structured information extracted from the ID document:
from pydantic import BaseModel, Field
from datetime import date
class IDDocumentInfo(BaseModel):
"""Structured information extracted from an ID document."""
name: str = Field(..., description="The full name of the person in the ID document")
date_of_birth: date = Field(..., description="The date of birth of the person in the ID document")
id_number: str = Field(..., description="The ID number of the document")
expiration_date: date = Field(..., description="The expiration date of the document")
[HOW-TO] Call the LLM with the message containing the image, and get an instance of the IDDocumentInfo class:
from langchain_core.messages import SystemMessage
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(..., model="google/gemma-4-26b-a4b-it:free")
extractor = llm.with_structured_output(IDDocumentInfo)
messages = [
SystemMessage("You are an assistant that extracts structured information from ID documents."),
message_with_image("Please extract the information from this ID document.", "data/passport-mario-rossi.png"),
]
results = extractor.batch([messages] * 5) # 5 samples, for voting
[HINT] for field-by-field voting, collections.Counter(getattr(r, field) for r in results).most_common(1) is your friend
| Image input requirements (OpenAI, at the time of writing) | |
|---|---|
| Supported file types | PNG (.png), JPEG (.jpeg, .jpg), WEBP (.webp), non-animated GIF (.gif) |
| Size limits | up to 512 MB total payload per request; up to 1500 image inputs per request |
| Other requirements | no watermarks or logos; no NSFW content; clear enough for a human to understand |
Solution: a walkthrough follows in the next (vertical) column — try on your own first!
⚠️ Spoiler alert: the walkthrough of the solution of Exercise 2 is about to start
master branch of lab-snippets
exercises branch, which only contains placeholdersStep 1: a model with vision capabilities, configured separately from the default one
base_url = os.environ.get("OPENAI_BASE_URL", "https://openrouter.ai/api/v1/")
api_key = os.environ.get("OPENAI_API_KEY") or input(f"Enter your API key for {base_url}: ")
# step 1: a model with vision capabilities (a separate env var, as other snippets may need a model with other capabilities)
vision_model = os.environ.get("VISION_MODEL", "google/gemma-4-26b-a4b-it:free")
vision_llm = ChatOpenAI(base_url=base_url, api_key=api_key, model=vision_model, temperature=1.0) # some randomness, for voting
VISION_MODEL): OPENAI_MODEL may point to a text-only model, used by other snippets
temperature=1.0: samples must differ a bit, otherwise voting is pointless (cf. self-consistency)Steps 2–3: a Pydantic class with documented fields, and a system prompt giving the context
# step 3: the output schema (dates as `date`, so that pydantic validates them, and code can compare them)
class IDDocumentInfo(BaseModel):
"""Structured information extracted from an ID document."""
name: str = Field(description="The full name of the holder, as written in the document: given names first, surname last.")
nationality: str = Field(description="The nationality of the holder, as written in the document.")
date_of_birth: date = Field(description="The date of birth of the holder.")
id_number: str = Field(description="The number of the document, as written in its visual zone (not in the machine-readable zone).")
expiration_date: date = Field(description="The date of expiry of the document.")
# step 2: system prompt (context, general instructions) + user prompt (specific instructions + the picture)
SYSTEM_PROMPT = """You are an assistant of the admission committee of a PhD programme.
You extract structured information from the pictures of the candidates' ID documents.
Report values exactly as written in the document; documents may be multilingual: prefer the Latin-alphabet version of each value."""
date: Pydantic validates them, and code can compare them (e.g. is the passport expired?)nationality added w.r.t. the hint: the schema is yours to designStep 2 (cont.): the user message is a list of typed pieces: the instruction (text) + the picture (base64)
def message_with_image(text: str, image_path: Path) -> HumanMessage:
"""A user message made of a text and a picture (base64-encoded), cf. the slides."""
mime_type = mimetypes.guess_type(image_path)[0] or "image/png" # e.g. "image/png" for .png files
data = base64.b64encode(Path(image_path).read_bytes()).decode("utf-8")
return HumanMessage(content=[
{"type": "text", "text": text},
{"type": "image", "base64": data, "mime_type": mime_type},
])
extractor = vision_llm.with_structured_output(IDDocumentInfo)
mimetypes.guess_type infers the MIME type from the file extension, so that .jpg pictures work tooimage piece into the provider’s format (e.g. OpenAI’s image_url with a data: URL)with_structured_output works with multimodal inputs exactly as with textual onesStep 4: query the model several times, in parallel, on the same picture
def sample(image_path: Path, samples: int = 5) -> list[IDDocumentInfo]:
"""Step 4: queries the model `samples` times (in parallel) about the same picture."""
messages = [SystemMessage(SYSTEM_PROMPT), message_with_image("Please extract the information from this ID document.", image_path)]
results = extractor.batch([messages] * samples, return_exceptions=True) # a failed sample (e.g. invalid JSON) is not fatal...
valid = [r for r in results if isinstance(r, IDDocumentInfo)]
if not valid: # ... unless all of them fail
raise RuntimeError(f"All {samples} samples failed, e.g.: {results[0]}")
return valid
.batch(...) sends the requests concurrently: $N$ samples cost $N\times$ tokens, but (roughly) not $N\times$ timereturn_exceptions=True: one invalid sample (e.g. malformed JSON, a date that is not a date) should not kill the whole run
Step 5: field by field, keep the most voted value, and flag fields with no clear majority for human review
def vote(results: list[IDDocumentInfo]) -> tuple[IDDocumentInfo, dict[str, float]]:
"""Step 5: for each field, the most voted value, and its agreement rate (fraction of results with that value)."""
voted, agreement = {}, {}
for field in IDDocumentInfo.model_fields:
value, count = Counter(getattr(r, field) for r in results).most_common(1)[0]
voted[field], agreement[field] = value, count / len(results)
return IDDocumentInfo(**voted), agreement
def flagged(agreement: dict[str, float]) -> list[str]:
"""Fields with no clear (i.e. absolute) majority: a human should check them."""
return [field for field, rate in agreement.items() if rate <= 0.5]
def extract(image_path: Path, samples: int = 5) -> tuple[IDDocumentInfo, list[str]]:
"""The whole pipeline: sample, vote, flag. Returns the voted information, and the fields to be reviewed by a human."""
info, agreement = vote(sample(image_path, samples))
return info, flagged(agreement)
IDDocumentInfo.model_fields: the fields of a Pydantic class (a dict keyed by field name); IDDocumentInfo(**voted) rebuilds (and validates) an object out of the voted valuesid_number)Files of this solution, in the lab-snippets repository (branch master):
lab-snippets/
├── data/
│ ├── passport-jean-dupont.png # inputs (running example)
│ ├── passport-mario-rossi.png
│ ├── passport-mohammed-ali.png
│ └── __init__.py # helpers to locate data files
├── snippets/
│ └── lecture_prompting/
│ └── exercise2/
│ └── id_extraction.py # sample, vote, flag
└── pyproject.toml # dependencies of all snippetspoetry run python -m snippets -l prompting -x 2 mario-rossi 5 # candidate's ID (or picture path), number of samples
OPENAI_API_KEY (and, optionally, OPENAI_BASE_URL, VISION_MODEL), cf. Free Access to LLMsAnswer in your own words, motivating your conclusions and using examples where useful.
Questions asking whether something is possible or desirable require more than a yes/no answer.
Implementation questions can be answered with an explanation or pseudocode unless executable code is explicitly requested.
Labels identify the primary learning target:
Practical answers should identify the technology used, without requiring memorisation of product-specific details (model names, prices, API signatures, versions, limits).
[know-how, theoretical] How would you turn a vague request into a reusable prompt? Discuss how you would distinguish instructions, context, input data, and the expected output.
[reasoning, theoretical] Should prompts be managed like source code? Explain what should happen when a prompt or its underlying model changes, and why a successful example is insufficient evidence that a prompt works well.
[know-how, theoretical] When would you use few-shot rather than zero-shot prompting? How would you choose examples, and how could poorly chosen examples make the results worse?
[reasoning, theoretical] What can asking a model to produce intermediate reasoning steps change about its answer? Discuss the limits of treating a generated explanation as evidence of correct reasoning.
[reasoning, theoretical] What is self-consistency, and when might it help? Can agreement among repeated answers be interpreted as the probability that the answer is correct?
[know-how, theoretical] When would you split a task into a chain of prompts rather than use one prompt? Explain how you would choose the intermediate results and deal with errors passed from one step to the next.
[reasoning, theoretical] What is the difference between asking for JSON, obtaining valid JSON, and obtaining an object that conforms to a schema? What does each guarantee about the usefulness and correctness of the result?
[know-how, practical] How would you use Pydantic to define structured information to extract from a document? Explain how field types, constraints, nesting, and descriptions contribute to the schema sent to the model.
[reasoning, theoretical] How should an extraction schema represent missing, unknown, or unreadable information? Discuss the difference between a required field, a nullable value, and a default value.
[notions, theoretical] How can structured output be enforced during generation? Compare constrained decoding, parsing and validation after generation, and using tool-call arguments as the output.
[know-how, practical] How would you request and consume structured output through an LLM client library? What should your application do if the model does not support the requested schema, refuses the request, or returns an unusable result?
[reasoning, practical] What does LangChain add to using a provider’s client directly? Explain how prompt templates, chat models, and structured outputs can be composed, and what portability this does and does not provide.
[know-how, theoretical] How would you turn a natural-language scoring policy into an LLM-filled checklist and a deterministic scoring function? Which sources of variability or error would this remove, and which would remain?
[notions, theoretical] What distinguishes a reasoning model from a model prompted to “think step by step”? Explain the role of reasoning tokens and the trade-off between computation at inference time, cost, latency, and answer quality.
[know-how, practical] How would you choose and adjust a reasoning budget for an application? What would you measure to decide whether spending more on reasoning is worthwhile?
[reasoning, theoretical] Is it always beneficial to give an LLM as much context as its window allows? Discuss relevance, position, cost, and the space needed to generate an answer.
[know-how, practical] How would you keep a long-running chat within a token budget? Compare trimming with summarisation, and explain what you would do if the context still exceeded the budget after compaction.
[reasoning, theoretical] How does provider-side prompt caching differ from caching complete responses in the client? Explain how prompt layout and context compaction affect the opportunity to reuse cached computation.
[know-how, practical] How would you extract typed information from an image of a document? Explain how you would supply the image and instructions, and check that the chosen model and API can process them.
[reasoning, theoretical] Several extractions of the same document disagree. What are the benefits and risks of voting separately on each field? How would you handle failed samples, ties, and a result whose fields are individually plausible but inconsistent together?
Compiled on: 2026-10-22 — printable version