Agentic AI · B.Sc. Applied Computer Science
How to sample tokens (softmax, temperature, top-$k$/$p$), the ReAct loop that turns a chat model into an agent, message types, and the system prompt / chat template.
After this week you can:
UserMessage / AssistantMessage / ToolResultMessage.Recall (W1): the model outputs a distribution over the vocabulary $P(W_t\mid W_{1:t-1})$. This week we open it up — logits → softmax → sample — and then loop with tools.
An LLM never emits a word directly: the network produces scores (one per vocabulary token), softmax turns them into a distribution, and we sample. The one-step pipeline:
$$ \underbrace{W_{1:t-1}}_{\text{previous tokens (W1)}}\;\xrightarrow{\text{Transformer}}\; \underbrace{\mathbf z\in\mathbb R^{|V|}}_{\text{logits: one score per token}}\; \xrightarrow{\text{softmax}}\; \underbrace{P(W_t\mid W_{1:t-1})}_{\text{distribution over }V}\; \xrightarrow{\text{sample}}\; w_t $$
Properties: every entry is positive and they sum to $1$ — a valid distribution. (Why? You prove it in the exercises.)
The softmax gives a distribution $P(\cdot)$ over the vocabulary $V$. To emit a token:
In code, that weighted draw is one line:
w_t = np.random.choice(len(p), p=p) # draw a token, weighted by the probabilities p
Then repeat autoregressively (Week 1's chain rule, one step at a time): append $w_t$, feed
$w_{1:t}$ back in, get the next distribution $P$ resp. p, sample again — until an end-of-sequence token.
The decoding strategy (temperature, top-$k$, top-$p$ — next slides) reshapes $P$ before this draw, trading determinism for diversity (see below).
Rescale the logits by temperature $T$ before softmax, changing how peaked the distribution is:
$$ P_T(W_t=i \mid W_{1:t-1} )=\frac{e^{z_i/T}}{\sum_j e^{z_j/T}}, \qquad T>0 $$
temperature=0.0 internally bypasses the softmax sampling and executes argmax(logits) directly to prevent a divide-by-zero error.)Why agents care:
How you then choose among many sampled sequence candidates — beam search vs. sampling, best-of-$N$, self-consistency — is Week 4.
Aside (train vs. inference). The shape of the learned distribution also depends on training (e.g. label smoothing / weight decay flatten it) — but that is baked into the model, distinct from the inference-time temperature knob here.
Sampling from the full distribution occasionally draws a very unlikely token from the long tail, which can derail the text. Both methods below zero out the tail, renormalise, then sample:
Example — probabilities $(0.6,\,0.3,\,0.05,\,0.03,\,0.02)$:
In practice these are often combined (e.g. top-$p$ + temperature — temperature reshapes
the distribution before truncation). Note the special case greedy = top-$k$ with $k{=}1$
(equivalently $T\to 0$).
(Newer variant: min-$p$ — keep every token within a factor $\texttt{min\_p}$ of the
top token's probability, i.e. $P(\text{token}) \ge \texttt{min\_p}\cdot p_{\max}$; the
cutoff adapts to the model's confidence.)
Setting them in practice: all these knobs are parametrizable —
temperatureandtop_pvia the OpenAI client against Ollama;top_k/min_pvia the nativeollamalibrary. You try them in lab.
Same model, different temperature (and top_p) per job — pick for "how many acceptable
answers are there?":
| Use case | temperature |
top_p |
Why |
|---|---|---|---|
| Tool calls, structured / JSON output, extraction, tests, linting | 0.0 (greedy) | — | one valid answer; must be repeatable |
| Factual Q&A, summarization, precise code edits | ~0.2–0.3 | ~0.9 | mostly deterministic, a little slack |
| General writing, drafting, explanation | ~0.7 | ~0.9–0.95 | natural variation without derailing |
| Brainstorming, creative, Tree-of-Thoughts candidates (W4) | ~1.0+ | ~0.95 | diversity is the goal |
temperature=0.0 (and a fixed seed if
the API exposes one) so runs are comparable.top_p ≈ 0.9–0.95 and adjust temperature; cranking both at
once makes output hard to predict.Two dials, not one. This is the parameter-level dial. The prompt-level dial — clarity, examples, a role, structure — is the other, and usually the stronger one. Reach for the prompt first, the temperature second.
Every knob above changes the distribution you sample from: temperature rescales it, top-$k$ and top-$p$ truncate it. Different setting, different text.
A second family makes generation faster while sampling from the same distribution — speculative decoding: a small, cheap model drafts the next few tokens, the real model verifies them all in one pass, and a modified rejection-sampling rule guarantees the emitted tokens are distributed exactly as the real model's (Leviathan et al., 2023; Chen et al., 2023). Reported: 2–3× less wall-clock time, identical outputs.
| changes the output? | what you buy | |
|---|---|---|
| temperature / top-$k$ / top-$p$ | yes — that is the point | determinism ↔ diversity |
| quantization | yes — approximates the weights | fits in less memory |
| speculative decoding | no — provably the same distribution | pure wall-clock win |
Why an agent course cares: an agent turn is many generations in a loop, so decode speed multiplies over every step of a ReAct trajectory — but it must never silently change what the model would have said, or your tool calls would depend on your inference settings. The mechanism, the acceptance rate $\alpha$, and how many tokens to draft are W7.
import numpy as np
def softmax(z, T=1.0):
z = np.asarray(z, float) / T
z = z - z.max() # numerical stability
e = np.exp(z)
return e / e.sum()
logits = np.array([2.0, 1.0, 0.2, -0.5])
for T in (0.5, 1.0, 2.0):
print(f"T={T}: {np.round(softmax(logits, T), 3)}")
#def top_p(p, thresh=0.9):
# idx = np.argsort(p)[::-1]
# keep = idx[np.cumsum(p[idx]) - p[idx] < thresh] # smallest set reaching thresh
# m = np.zeros_like(p); m[keep] = p[keep]
# return m / m.sum()
def top_p(p, thresh=0.9):
sorted_indices = np.argsort(p)[::-1]
sorted_probs = p[sorted_indices]
cum_probs = np.cumsum(sorted_probs)
# Shift cumulative sum to include the first token that crosses the threshold
cutoff_mask = cum_probs - sorted_probs < thresh
keep_indices = sorted_indices[cutoff_mask]
masked_p = np.zeros_like(p)
masked_p[keep_indices] = p[keep_indices]
return masked_p / masked_p.sum()
p = softmax(logits, 1.0)
print("full :", np.round(p, 3))
print("top-p:", np.round(top_p(p, 0.9), 3))
From generating to acting. So far we have a fluent text generator and know how to sample from it. An agent must also reason before acting.
Chain-of-Thought (CoT; Wei et al., 2022): prompt the model to emit intermediate reasoning steps before the final answer ("think step by step"). CoT turns a single monolithic prediction into an explicit, multi-step trajectory.
At first glance CoT is a prompting trick. Under the hood it changes how much computation the Transformer can spend on a problem:
Compute per token — the "scratchpad" effect. An autoregressive Transformer performs a fixed number of operations (layers, attention heads, feed-forward passes) per generated token.
Causal attention turns steps into premises. Each token attends only to earlier tokens, so writing out step 1 makes step 2 cheaper: the intermediate result becomes part of the prefix, and the model conditions on its own sub-result instead of re-deriving it.
In native reasoning models (e.g. o1, DeepSeek-R1). CoT is no longer a manual prompt technique. Such models are trained — with reinforcement learning (DeepSeek-AI, 2025) — to produce a reasoning trajectory before answering, and to spend a larger "thinking budget" on harder problems. The reasoning is emitted as a separately marked span, which is what lets the runtime treat it differently from the answer.
CoT improves the reasoning, but the model still commits to the entire chain before anything happens in the world. The control-theory terms name the difference exactly:
Classic CoT is open-loop: the model reasons, but it cannot act on the outside world, gather new data, or notice that step 3 rests on a false premise from step 2. Every fact it uses comes strictly from its parametric memory (training data), so an early error is carried, undetected, to the end.
Acting changes the requirement. The moment a step has an effect — a query, a file write, an email — its outcome is information the model did not have, and the next step ought to depend on it. Feeding that outcome back is the closed loop, and that is the step from CoT to ReAct.
ReAct = Reasoning + Acting (Yao et al., 2023): Extends CoT into a closed-loop feedback cycle. It interleaves CoT-style Thoughts with concrete Actions (tool executions) and environment Observations (tool outputs), repeating until the task is solved:
$$\text{Thought}_k \;\longrightarrow\; \text{Action}_k (\text{tool call}) \;\longrightarrow\; \text{Observation}_k (\text{tool result}) \;\longrightarrow\; \text{Thought}_{k+1} \;\dots$$
| Paradigm | Reasons (CoT) | Acts (Tools) | Grounds on Feedback | Control Type |
|---|---|---|---|---|
| Chain-of-Thought (CoT) | ✅ | ❌ | ❌ | Open-loop (Static) |
| ReAct | ✅ | ✅ | ✅ | Closed-loop (Dynamic) |
ReAct grounds intermediate thoughts in real-world feedback—preventing hallucination drift and enabling recovery when an execution step fails. This interleaving loop forms the operational core of the agent executor.
Two ways to plan. ReAct plans implicitly, just in time: there is no plan object anywhere. The "plan" is whatever the next thought turns out to be, re-derived from the whole history at every turn. The alternative is explicit planning — first produce a plan (a list of steps, a tree, a graph), then execute it, replanning when a step fails.
| just-in-time (ReAct) | explicit plan | |
|---|---|---|
| where the plan lives | only in the history | a separate, inspectable object |
| adapts to a surprise | immediately, by construction | needs an explicit replan |
| can be checked before acting | no | yes |
| cost | one call per step | an extra planning call |
| failure mode | wanders, repeats itself, loses the thread | plan goes stale after the first contradicting observation |
W3–W5 take the explicit route — decomposition (W3), deliberate search over plans (Tree-of-Thoughts, W4), planning as a decision problem (MDPs, W5). This week stays with the implicit loop, which is what most production agents actually run.
history ← [ system_prompt ] # created ONCE; persists across turns (the session)
function prompt(user_input): # called once per user turn
history.append(user_input)
loop:
response ← LLM(history, tools) # condition on the FULL history so far
if response has no tool calls:
history.append(response) # final answer → stop
return response.text
for each call in response.tool_calls: # a batch of ≥1 calls
result ← execute(call) # runtime runs the tool
history.append(call, result) # feed the observation back
# loop again: the next LLM call sees the appended results
Reading it: ← is assignment. history (the conversation) is created once and only
ever grows via append — it is never reset. Each user turn calls prompt(), which
appends the new message; the loop appends the assistant reply and each tool result. Every
LLM(history, …) re-sends the whole growing history.
The pseudocode above as a control-flow graph — the minimal ReAct loop:
The only exit is no tool calls → return; after running tools the loop always returns to the model, which re-reads the appended results. Note who decides to stop: the model does, by emitting no tool call. A richer loop adds a separate Goal reached? test the runtime evaluates — the goal-loop refinement (W13, Sanoja 2026).
Key points
Note: Commercial AI-Assistants/agents use the same loop, see e.g. for Claude Code:
The model has no built-in knowledge of available tools — the same base model powers many agents, each with a different toolset, so every call must tell it which tools exist right now (it can't call what it doesn't know).
To let the model use a tool you advertise a schema — name, description, and a JSON-Schema of
its parameters — via the tools argument of the model call:
{ "type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"] } } }
What is fixed, and what is yours. The envelope — which fields exist and how they nest — is fixed by the API you call, not by the model, and it differs between providers. The
parametersfollow the JSON Schema standard. What you own is the name, the description, and the argument definitions — and the description is what the model reasons over when deciding whether to call. (Concrete envelopes per provider: Exercise 13.)
message.tool_calls — a list of {id, name, arguments}
(arguments is a JSON string).tool_call_id, which pairs result ↔ request — needed when a turn issues several calls.How the call comes back — two routes. With a tool-calling-tuned model (rung 3 of the W1
ladder) you pass tools=[…] and get a structured tool_calls object back — native function
calling, which we use throughout. Rung 3 is what makes that reliable, but it is not a
requirement: a model without it can still be driven prompt-based — write the tool descriptions
into the prompt text, then parse the call back out of the generated text
("stop-and-parse"). That works with any instruction-following model and is markedly more brittle;
you build it in Exercise 14.
(Training is not one fixed recipe either: Toolformer learns tool use self-supervised — the model inserts candidate API calls into ordinary text and keeps only those that reduce its own next-token loss (Schick et al., 2023).)
Sticking to native (JSON) tool calling, the action is always a JSON tool call — but the tool comes in two styles:
get_weather(city), send_email(to, body). One clear
capability each; easy to validate and keep safe.run_python(code) whose argument is code. Far more expressive (loop, compose several steps,
use variables within one action) — but it runs arbitrary code, so it needs strong
sandboxing (W12) and is harder to constrain.Both are the same JSON tool call; the code tool just carries code in arguments. Same ReAct
loop — only the tool differs. (Which task suits which, and what the sandbox must block:
Exercise 11.)
Two axes, not a menu. Format — how the call comes back: native JSON vs prompt-based (Function calling slide; you build the prompt-based route in Exercise 14). Tool kind — what the tool does: specific vs code tool (this slide). The axes are independent: a code tool can be requested natively or parsed out of free text. Some frameworks (e.g. smolagents) take the code · prompt-based corner — code is the model's own output, parsed from text instead of wrapped in JSON. We stay in the specific · native JSON corner throughout.
Definition. MCP is an open protocol / contract by which a server exposes tools, data (resources), and prompt-templates, and an agent's runtime discovers and communicates with them — decoupling tool providers from agents so a tool written once works with any MCP-capable runtime.
So far each tool is hand-wired into our runtime. MCP (Model Context Protocol) is an open standard (2024) that decouples tools from the agent: tools, data, and prompts are exposed by an MCP server, and any MCP-capable client (the agent runtime) can discover and call them — without bespoke glue per tool.
tool_calls schema from the
Function calling slide).A third, orthogonal axis. MCP standardises how tools are exposed and discovered, not how the model emits a call — an MCP tool is still invoked by ordinary function calling underneath. (How it composes with the other two axes: Exercise 13(c).)
MCP is only a contract. It standardises the connection & discovery (a client can ask a server at runtime what tools it offers — unlike static function-calling), not the quality of what's behind it. A server that returns raw PDFs, or one record at a time, is useless to an agent — the underlying API must be agent-friendly (deterministic filtering / sorting, machine-readable output). Agents don't replace deterministic workflows; they need strong deterministic support underneath (cf. W3: fix the workflow when the how is known).
Takeaway. The model knows nothing about MCP — the runtime does. The model just sees a list of tools and emits an ordinary tool call; the runtime handles discovery, transport (JSON-RPC), routing, and security.
Everything in this section is one pattern — Tool Use (a.k.a. function calling). Its loop (Gulli, 2025): define → the model decides → it emits a structured call → the runtime executes → observe the result → the model uses it — which is the ReAct loop with tools.
Definition. The Agent–Computer Interface (ACI) is the interface a tool presents to a model: the tool's name, its description, its argument names and types, and the shape of the result handed back. It is the agent-side counterpart of a human user interface (Schluntz & Zhang, 2024).
These are the fields the Function calling slide attributed to the developer. The call's JSON envelope belongs to the endpoint and is fixed; the content of those fields is a design problem, and it is the whole of the ACI.
A human UI is judged by whether a person can operate it without a manual; an ACI is judged by whether a model can operate it without guessing. Anthropic report investing as much effort in the ACI as in a human UI — on SWE-bench, tuning the tools took more effort than tuning the prompt (Schluntz & Zhang, 2024). Their design rules:
reasoning field ordered first — buys
forward passes to settle the decision before the format locks it in. Reasoning models apply the
same principle: thinking first, answer second.enum over a free string, and one unambiguous unit
over "a number".The mechanism is unchanged. ACI concerns the content of
description+parameters, not the call machinery of the Function calling slide. Its effect is measured in call quality: fewer missed calls, fewer malformed arguments, fewer retries.
Workflows vs. agents (start simple). Not every task needs this loop. A workflow (LLM calls on predefined code paths) is more predictable and cheaper; a full agent (the LLM directs its own steps) is reserved for open-ended tasks with verifiable outcomes (Schluntz & Zhang, 2024).
W1 introduced the conversation as a list of role-tagged messages — system, user,
assistant, tool. Those role strings are what goes on the wire. In code you want a type per
role, because each carries different fields — this is what the lab's history holds:
| Wire role (W1) | Type (this week's lab) | Holds |
|---|---|---|
user |
UserMessage |
the user's text |
assistant |
AssistantMessage |
text and/or tool_calls (id, name, arguments) |
tool |
ToolResultMessage |
tool_call_id (links to the call) + the tool's output |
Same three things, named for code rather than for JSON. The new part this week is the pairing:
an AssistantMessage may carry several tool_calls, and each needs its own
ToolResultMessage carrying the matching tool_call_id.
User asks "What's the weather in Berlin?", with a get_weather tool available.
history: [ system, User("What's the weather in Berlin?") ]
turn 1 LLM(history) → Assistant(tool_calls=[get_weather(city="Berlin")]) # no text yet
runtime runs get_weather("Berlin") → "12°C, rainy"
history += Assistant(tool_call) , ToolResult("12°C, rainy")
turn 2 LLM(history) → Assistant("It's 12°C and rainy in Berlin.") # no tool call → STOP
As typed messages (previous slide), history grew to:
UserMessage("What's the weather in Berlin?")AssistantMessage(tool_calls=[{name:"get_weather", arguments:{city:"Berlin"}}])ToolResultMessage(tool_call_id=…, content="12°C, rainy")AssistantMessage("It's 12°C and rainy in Berlin.") ← returnedTwo turns, one tool call — and every turn re-sent the whole history.
The next cell runs this same round-trip against a real model.
In a single turn, the model may issue multiple tool calls in parallel (e.g., get_weather("Berlin") AND get_weather("Paris")). The runtime returns multiple ToolResultMessage objects. The model uses tool_call_id to match each result back to its original request.
# The SAME round-trip as the trace above, in real code (one loop iteration).
import json
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "qwen2.5:7b"
def get_weather(city): # the actual tool (runtime side)
return f"12°C, rainy in {city}"
tools = [{"type": "function", "function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}}]
msgs = [{"role": "user", "content": "What's the weather in Berlin?"}]
# turn 1: model requests a tool
r = client.chat.completions.create(model=MODEL, messages=msgs, tools=tools)
msg = r.choices[0].message
if not msg.tool_calls:
# a small model sometimes answers in text instead of calling: there is nothing to feed back,
# so the second turn must not happen at all
print("Model responded with text instead of calling a tool:", msg.content)
else:
call = msg.tool_calls[0]
args = json.loads(call.function.arguments)
result = get_weather(**args) # the runtime executes it
# feed the result back; turn 2: model gives the final answer
msgs.append(msg) # assistant tool-call msg
msgs.append({"role": "tool", "tool_call_id": call.id, "content": result})
final = client.chat.completions.create(model=MODEL, messages=msgs, tools=tools)
print(final.choices[0].message.content)
The model has no memory: the entire conversation is re-sent every turn.
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
...<|im_end|>
<|im_start|>assistant
This format is ChatML (<|im_start|> … <|im_end|>), used by e.g. Qwen — our lab
model qwen2.5 — and originally OpenAI. Other families use different delimiters (Llama,
Mistral); see the format note on the previous tool-calling slide.
Tool use adds a tool role for results, and the model's tool request is carried in the assistant turn — the exact tokens are model-specific.
Each LLM call sends more than the conversation. The loop maintains history — the
running conversation (system prompt + all prior turns + this turn's tool calls/results).
Around it, the runtime assembles extra context on every call:
context sent to the LLM =
history # system prompt + all turns + this turn's tool calls/results (W2)
+ tool schemas # the advertised tools (W2)
+ retrieved documents # RAG results for this query (W8)
+ long-term memory # relevant remembered facts (W9)
history only grows — so when it gets too large it is compacted (summarised) — W9.history; the runtime injects them each call —
memory & RAG are fetched by similarity (embeddings, W6).This is context engineering (W1): deciding what to assemble into the context each call — and, under a finite budget, what to retrieve (W8) or compact (W9) — is the discipline itself.
historyis a construction, not a transcript. The runtime chooses what to send: it can prune, merge, or summarise turns (drop a redundant reply, elide a consumed tool output) — as long as it keeps a pairedtool_call/tool_resulttogether. The messages you send ≠ the raw conversation.
The context window (W7) is a fixed token allowance, and every part of the context spends
from it: more tool schemas or retrieved documents ⇒ less room for history, and vice-versa.
Because the model is stateless, the whole budget is re-assembled and re-sent on every call — so each token costs latency and money every turn, not once.
Deciding what to include, retrieve, or compact to stay within this budget is a central agent-design problem — hence embeddings / retrieval (W6 / W8) and compaction (W9). It is context engineering (W1) in practice.
The same reasoning governs procedural instructions. Rather than keeping every how-to resident
in the system prompt, an agent can file them as skills — directories whose one-line
description is all that occupies the context until a task matches it, at which point the full
instructions load (W9). Tool schemas, retrieval and skills are three instances of one move:
index first, load on demand.
To maximize GPU KV-cache reuse (Prompt Caching), the static parts (System Prompt and Tool Schemas) MUST be placed at the very beginning of the context. Variable data (user messages, retrieved docs) should come after.
get_weather. A while loop, a growing message list, a json.loads.run_agent() from scratch (no framework) — the loop function at the
heart of any agent runtime. Details in week-02-exercises.ipynb.ollama serve running; tool calling gets unreliable below ~3B.tool_calls; the
runtime executes and feeds results back. MCP standardises exposing tools.UserMessage / AssistantMessage / ToolResultMessage in a persistent,
growing history.Next week (W3): breaking a task apart — task decomposition & a coding/web agent.
Further reading — tool use. Gulli, A. (2025). Agentic Design Patterns, Ch. 5 — Tool Use (Function Calling) (Springer): the tool-use process, agents as tools, code execution, and extensions vs. function calling.
Further reading — MCP. Gulli, A. (2025). Agentic Design Patterns, Ch. 10 — Model Context Protocol (Springer): MCP vs. function calling, client/server architecture, tools · resources · prompts, transport (STDIO / HTTP+SSE), and the agent-friendly-API caveat.