Build and evaluate reasoning controllers: single traces, answer voting, and bounded tree search for multi-step LLM decisions.
Compressed embeddings can retrieve the right policy evidence and still leave the assistant with a decision to make. Retrieval isn't the end of the task. An assistant may have the right facts and still choose the wrong action because it skips a condition, mishandles arithmetic, or commits too early to one plan.
Treat extra inference work as an engineering decision. You'll build a small release-resolution controller for a risky code change:
Long rationales aren't the target. The target is better measurable decision accuracy under a token, latency, and safety budget.
Suppose a repository has a candidate release. Unit tests pass, the security scan is clear, and a reviewer has approved the diff. Policy permits auto-merge only when all three facts are true. A direct response may still overlook one condition and suggest a force-merge or escalation unnecessarily.
A useful outward artifact is a short decision record: facts used, checks applied, and final action. It's smaller than an open-ended rationale, easy to score, and safe to compare against deterministic policy logic.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class ReleaseCase:
5 tests_passed: bool
6 security_scan_clear: bool
7 reviewer_approved: bool
8
9def decision_record(case: ReleaseCase) -> dict[str, object]:
10 checks = {
11 "tests_passed": case.tests_passed,
12 "security_scan_clear": case.security_scan_clear,
13 "reviewer_approved": case.reviewer_approved,
14 }
15 action = "merge_release" if all(checks.values()) else "manual_review"
16 return {"checks": checks, "final_action": action}
17
18record = decision_record(
19 ReleaseCase(tests_passed=True, security_scan_clear=True, reviewer_approved=True)
20)
21for name, passed in record["checks"].items():
22 print(f"{name}: {passed}")
23print(f"final_action: {record['final_action']}")1tests_passed: True
2security_scan_clear: True
3reviewer_approved: True
4final_action: merge_releaseThis code isn't an LLM. It's the oracle that your prompt variants must match. Before increasing model compute, define an output contract and a scorer.
Wei et al. introduced few-shot CoT by placing worked intermediate steps in prompt examples. Their experiments found gains on arithmetic, commonsense, and symbolic reasoning benchmarks for sufficiently large models, including a strong GSM8K result with PaLM 540B.[1] Kojima et al. later showed that the zero-shot trigger "Let's think step by step" could improve several benchmark reasoning tasks without worked examples.[2]
Those papers establish techniques to evaluate, not a production law. On your endpoint and task, a visible scratchpad may help, do nothing, or add cost. If an API offers a native reasoning control, evaluate that option as another strategy rather than assuming that extra visible text helps.
🔬 Research insight: More reasoning text isn't always better. Zheng et al. tested CoT and its variants (including ToT and ReAct) on nine pattern-based in-context-learning benchmarks across 16 models and found they consistently underperformed plain direct answering, with the gap widening as more demonstrations were added. Even long-CoT reasoning models that spent far more tokens didn't overcome the effect.[3] When a task is really pattern matching from examples, a visible scratchpad can add noise instead of signal. That's the point of the eval gate: measure the win, don't assume it.
This tradeoff also shapes a build-versus-buy decision you'll face in production. You can orchestrate reasoning yourself with prompt scaffolding (the CoT, self-consistency, and tree-search controllers in this chapter), or call a native reasoning model that performs its own internal reasoning before answering. Scaffolding keeps each step visible, auditable, and cheap to swap, but you own the token and latency budget. A native reasoning model can lift accuracy on genuinely multi-step problems, yet it hides its reasoning, bills you for it, and gives you fewer control points to gate. Treat the two as competing candidates behind the same eval gate rather than assuming the newer option wins.
For a production workflow, don't ask the model to reveal unrestricted inner reasoning. Ask it to produce reviewable artifacts:
1Use only the supplied release facts and policy rules.
2Return:
31. required_checks: each policy predicate with pass/fail
42. final_action: one allowed action enum
53. operator_message: one sentence
6
7Facts:
8- tests_passed: true
9- security_scan_clear: true
10- reviewer_approved: true
11
12Policy:
13- merge_release is allowed only when tests pass,
14 the security scan is clear, and review approval exists.The checks are useful because a missed predicate becomes observable. They aren't proof of faithful hidden cognition. Turpin et al. showed that CoT explanations can rationalize outputs influenced by hidden biasing features without mentioning those features.[4] Log inputs, actions, validations, and outcomes; don't treat eloquent reasoning text as an audit guarantee.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Candidate:
5 name: str
6 checks: set[str]
7 final_action: str
8
9required_checks = {
10 "tests_passed",
11 "security_scan_clear",
12 "reviewer_approved",
13}
14expected_action = "merge_release"
15candidates = [
16 Candidate("direct", {"tests_passed"}, "manual_review"),
17 Candidate("structured_trace", required_checks, "merge_release"),
18]
19
20for candidate in candidates:
21 coverage = len(candidate.checks & required_checks) / len(required_checks)
22 action_ok = candidate.final_action == expected_action
23 print(f"{candidate.name}: coverage={coverage:.0%}, action_ok={action_ok}")1direct: coverage=33%, action_ok=False
2structured_trace: coverage=100%, action_ok=TrueHere, the trace is useful because it meets the scored contract. It doesn't win merely because it contains more words.
Zero-shot CoT supplies an instruction and lets the model choose its intermediate format. Few-shot CoT gives one or more solved examples, so it can teach both decomposition and the final answer shape. Native structured output enforcement is better when available; examples are still useful when the prompt must communicate task-specific checks.
1example = """Example:
2facts: tests_passed=true, security_scan_clear=false, reviewer_approved=true
3required_checks: tests_passed=true, security_scan_clear=false, reviewer_approved=true
4final_action: manual_review"""
5
6case = "facts: tests_passed=true, security_scan_clear=true, reviewer_approved=true"
7zero_shot = f"Evaluate release policy step by step.\n{case}\nfinal_action:"
8few_shot = f"{example}\n\nNow evaluate:\n{case}\nrequired_checks:\nfinal_action:"
9
10print(f"zero_shot_has_example: {'Example:' in zero_shot}")
11print(f"few_shot_has_example: {'Example:' in few_shot}")
12print(f"few_shot_requests_checks: {'required_checks:' in few_shot}")1zero_shot_has_example: False
2few_shot_has_example: True
3few_shot_requests_checks: TrueOne structured trace can fail because generation takes an unlucky path. Self-Consistency replaces reliance on one path with several sampled paths and chooses the most consistent final answer.[5] The vote operates on extracted answers, not on whose rationale sounds best.
To obtain different candidates, use stochastic decoding, usually with a nonzero temperature, rather than rerunning one deterministic decode. The earlier Decoding Strategies lesson explains that control. Keep it fixed in your experiment and log it with the sample count.
On the original benchmark setting, Wang et al. reported a 17.9 percentage-point GSM8K improvement over CoT prompting for PaLM 540B.[5] That number is evidence for the method on those benchmarks. It's not your expected support-resolution gain. Measure your own cases and sample cost.
Model outputs rarely use exactly one spelling. Your controller should map harmless variants to one allowed action and reject unknown outputs before voting.
1from collections import Counter
2
3ALIASES = {
4 "merge release": "merge_release",
5 "merge_release": "merge_release",
6 "auto merge approved change": "merge_release",
7 "manual review": "manual_review",
8}
9
10def canonicalize(text: str) -> str | None:
11 normalized = text.strip().lower().replace("-", " ")
12 return ALIASES.get(normalized)
13
14samples = [
15 "Merge release",
16 "merge_release",
17 "Auto merge approved change",
18 "manual review",
19 "force merge immediately",
20]
21votes = Counter(action for text in samples if (action := canonicalize(text)))
22winner = votes.most_common(1)[0][0] if votes else "manual_review"
23
24print(f"accepted_samples: {sum(votes.values())}/{len(samples)}")
25print(f"votes: {dict(votes)}")
26print(f"winner: {winner}")1accepted_samples: 4/5
2votes: {'merge_release': 3, 'manual_review': 1}
3winner: merge_releaseA 2 to 2 tie, zero accepted outputs, or a narrow plurality with many rejected outputs shouldn't silently become an automated action. Add an abstention rule. The winning share must use all sampled outputs as its denominator, including strings that failed to parse.
1from collections import Counter
2
3def decide(votes: list[str], total_samples: int, minimum_share: float = 0.6) -> str:
4 if not votes:
5 return "manual_review"
6 counts = Counter(votes)
7 winner, count = counts.most_common(1)[0]
8 share = count / total_samples
9 tied = len(counts) > 1 and counts.most_common(2)[0][1] == counts.most_common(2)[1][1]
10 if tied or share < minimum_share:
11 return "manual_review"
12 return winner
13
14strong = ["merge_release", "merge_release", "merge_release", "manual_review"]
15split = ["merge_release", "merge_release", "manual_review", "manual_review"]
16mostly_rejected = ["merge_release", "merge_release"]
17
18print(f"strong_vote: {decide(strong, total_samples=4)}")
19print(f"split_vote: {decide(split, total_samples=4)}")
20print(f"mostly_rejected_vote: {decide(mostly_rejected, total_samples=5)}")
21print(f"no_valid_votes: {decide([], total_samples=5)}")1strong_vote: merge_release
2split_vote: manual_review
3mostly_rejected_vote: manual_review
4no_valid_votes: manual_reviewUse a held-out fixture set before calling the strategy ready. The fixture represents five sampled final actions returned for each case; the controller compares first-sample accuracy with five-sample voting accuracy.
1from collections import Counter
2
3fixtures = {
4 "approved_clean_release": {
5 "expected": "merge",
6 "samples": ["review", "merge", "merge", "merge", "force"],
7 },
8 "scan_failed": {
9 "expected": "review",
10 "samples": ["review", "review", "force", "review", "hold"],
11 },
12 "review_missing": {
13 "expected": "review",
14 "samples": ["merge", "review", "review", "review", "merge"],
15 },
16}
17
18single_correct = 0
19vote_correct = 0
20for item in fixtures.values():
21 winner = Counter(item["samples"]).most_common(1)[0][0]
22 single_correct += item["samples"][0] == item["expected"]
23 vote_correct += winner == item["expected"]
24
25total = len(fixtures)
26print(f"single_trace_accuracy: {single_correct / total:.0%}")
27print(f"vote_5_accuracy: {vote_correct / total:.0%}")
28print(f"model_calls: single={total}, vote_5={total * 5}")1single_trace_accuracy: 33%
2vote_5_accuracy: 100%
3model_calls: single=3, vote_5=15This fixture is intentionally small and deterministic: it tests controller logic. A real release decision needs representative labeled cases, real model samples, token counts, latency, refusal rates, and cost.
Voting helps when independent paths tend to converge on the same short answer. It doesn't deliberately revisit earlier choices. Tree-of-Thoughts (ToT) represents partial solutions as search states, generates alternatives, evaluates the states, and preserves only branches worth extending.[6]
Yao et al. evaluated ToT on tasks built for planning and search. On Game of 24, their GPT-4 ToT setup solved 74% of tasks while their CoT baseline solved 4%.[6] The narrower conclusion is not "use trees everywhere." If a problem has verifiable partial states and meaningful backtracking, search can rescue a bad early move.
For a release gate, consider a workflow whose final recommendation must be supported by two observations: a current security scan and a reviewer approval record. A controller can expand legal steps instead of letting a model invent a final action before evidence is present.
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class State:
5 security_clear: bool | None = None
6 reviewer_approved: bool | None = None
7 final_action: str | None = None
8
9def expand(state: State) -> list[tuple[str, State]]:
10 next_states: list[tuple[str, State]] = []
11 if state.security_clear is None:
12 next_states.extend([
13 ("security_scan:clear", State(True, state.reviewer_approved)),
14 ("security_scan:failed", State(False, state.reviewer_approved)),
15 ])
16 if state.reviewer_approved is None:
17 next_states.extend([
18 ("review:approved", State(state.security_clear, True)),
19 ("review:rejected", State(state.security_clear, False)),
20 ])
21 if (
22 state.security_clear is not None
23 and state.reviewer_approved is not None
24 and state.final_action is None
25 ):
26 action = "merge_release" if state.security_clear and state.reviewer_approved else "block_release"
27 next_states.append((action, State(state.security_clear, state.reviewer_approved, action)))
28 return next_states
29
30frontier = [State()]
31for depth in range(3):
32 generated = [item for state in frontier for item in expand(state)]
33 print(f"depth_{depth + 1}: {[action for action, _ in generated]}")
34 frontier = list(dict.fromkeys(state for _, state in generated))1depth_1: ['security_scan:clear', 'security_scan:failed', 'review:approved', 'review:rejected']
2depth_2: ['review:approved', 'review:rejected', 'review:approved', 'review:rejected', 'security_scan:clear', 'security_scan:failed', 'security_scan:clear', 'security_scan:failed']
3depth_3: ['merge_release', 'block_release', 'block_release', 'block_release']The two evidence-gathering orders converge on outcome-bearing states. Only the state with a clear scan and an approved review can merge; a failed scan or rejected review blocks the release. In the next lesson, tool calls will populate those outcomes from real APIs.
Game of 24 is useful because the evaluator is exact: arithmetic either reaches 24 using each input once or it doesn't. The solver below explores partial equations with breadth-first search and returns a verified solution.
1from fractions import Fraction
2from itertools import combinations
3
4def combine(left: tuple[Fraction, str], right: tuple[Fraction, str]) -> list[tuple[Fraction, str]]:
5 a, a_expr = left
6 b, b_expr = right
7 outcomes = [
8 (a + b, f"({a_expr} + {b_expr})"),
9 (a - b, f"({a_expr} - {b_expr})"),
10 (b - a, f"({b_expr} - {a_expr})"),
11 (a * b, f"({a_expr} * {b_expr})"),
12 ]
13 if b:
14 outcomes.append((a / b, f"({a_expr} / {b_expr})"))
15 if a:
16 outcomes.append((b / a, f"({b_expr} / {a_expr})"))
17 return outcomes
18
19def solve_24(numbers: list[int]) -> str | None:
20 frontier = [[(Fraction(number), str(number)) for number in numbers]]
21 while frontier:
22 state = frontier.pop(0)
23 if len(state) == 1 and state[0][0] == 24:
24 return state[0][1]
25 for i, j in combinations(range(len(state)), 2):
26 remainder = [item for k, item in enumerate(state) if k not in (i, j)]
27 frontier.extend([remainder + [result] for result in combine(state[i], state[j])])
28 return None
29
30solution = solve_24([4, 5, 6, 7])
31print(f"solution_found: {solution is not None}")
32print(f"expression: {solution}")1solution_found: True
2expression: ((6 - 4) * (5 + 7))
An LLM evaluator isn't an arithmetic oracle. If it scores an apparently simple but dead branch above a non-obvious solvable branch, an aggressive beam can remove the answer before expansion.
1branches = [
2 {"move": "6 * 4 = 24 first", "solvable": False, "weak_score": 0.95, "exact_score": 0.0},
3 {"move": "5 + 7 = 12 first", "solvable": True, "weak_score": 0.40, "exact_score": 1.0},
4 {"move": "7 - 5 = 2 first", "solvable": False, "weak_score": 0.35, "exact_score": 0.0},
5]
6
7def keep_one(score_name: str) -> dict[str, object]:
8 return max(branches, key=lambda branch: branch[score_name])
9
10weak_choice = keep_one("weak_score")
11exact_choice = keep_one("exact_score")
12print(f"weak_evaluator_keeps_solution: {weak_choice['solvable']}")
13print(f"exact_evaluator_keeps_solution: {exact_choice['solvable']}")
14print(f"risk: beam_width_1 can prune the valid branch")1weak_evaluator_keeps_solution: False
2exact_evaluator_keeps_solution: True
3risk: beam_width_1 can prune the valid branchThe production implications are concrete:
Direct prompting, one trace, voting, and tree search aren't maturity levels. They are candidates with different accuracy and serving cost. Start with the cheapest candidate, then promote a more expensive strategy through an eval gate only when held-out results require it.
1results = [
2 {"strategy": "direct", "accuracy": 0.76, "p95_ms": 190, "calls": 1},
3 {"strategy": "single_trace", "accuracy": 0.84, "p95_ms": 360, "calls": 1},
4 {"strategy": "vote_5", "accuracy": 0.94, "p95_ms": 740, "calls": 5},
5 {"strategy": "tree_search", "accuracy": 0.96, "p95_ms": 1840, "calls": 14},
6]
7
8minimum_accuracy = 0.90
9latency_budget_ms = 900
10eligible = [
11 row for row in results
12 if row["accuracy"] >= minimum_accuracy and row["p95_ms"] <= latency_budget_ms
13]
14selected = min(eligible, key=lambda row: (row["calls"], row["p95_ms"]))
15
16for row in results:
17 print(f"{row['strategy']}: accuracy={row['accuracy']:.0%}, p95_ms={row['p95_ms']}, calls={row['calls']}")
18print(f"selected: {selected['strategy']}")1direct: accuracy=76%, p95_ms=190, calls=1
2single_trace: accuracy=84%, p95_ms=360, calls=1
3vote_5: accuracy=94%, p95_ms=740, calls=5
4tree_search: accuracy=96%, p95_ms=1840, calls=14
5selected: vote_5These numbers are example evaluation results, not a benchmark claim. In your system, keep a table with:
| Metric | Why it matters |
|---|---|
| Action accuracy or task success | Extra reasoning must change correct outcomes |
| Unsafe-action and abstention rates | Reliability includes knowing when not to act |
| Input, output, and reasoning tokens | Sampling and search multiply spend |
| p50 and p95 latency | Long tails can make support interactions unusable |
| Parse and schema failures | A correct thought is useless if the runtime can't consume its action |
All runnable experiments above operate on provided facts or deterministic state. A real release decision requires current CI status, security scan output, and review state from source systems. Reasoning alone can't obtain those observations.
ReAct interleaves reasoning traces and task-specific actions so new observations can update the next decision.[7] The production handoff isn't a saved inner monologue. It's a validated action request, a controlled execution result, and a bounded next decision:
1Need: security scan status is not present in supplied facts.
2Next action request: get_security_scan(run_id="ci-1482")
3Runtime responsibility: validate authorization, execute call, log result.
4Next decision: apply policy only after observation is returned.The next lesson implements that action boundary with typed function calls, schemas, errors, and safe execution.
Create twenty release-gate fixtures with policy-grounded final actions. Collect direct, structured-trace, and five-sample candidate outputs from one model endpoint. Canonicalize the final action, abstain on weak votes, and produce a comparison table with accuracy, unsafe actions, abstentions, tokens, and p95 latency. Add search only for cases where choosing an action requires sequencing multiple checks or backing out of a failed plan.
Answer every question, then check your score. Score above 75% to mark this lesson complete.
10 questions remaining.
Chain-of-Thought Prompting Elicits Reasoning in Large Language Models.
Wei, J., et al. · 2022 · NeurIPS
Large Language Models are Zero-Shot Reasoners.
Kojima, T., et al. · 2022
The Curse of CoT: On the Limitations of Chain-of-Thought in In-Context Learning.
Zheng, T., Chen, Y., Li, C., et al. · 2025 · arXiv preprint
Language Models Don't Always Say What They Think: Unfaithful Explanations in Chain-of-Thought Prompting
Miles Turpin, Julian Michael, Ethan Perez, Samuel R. Bowman · 2023
Self-Consistency Improves Chain of Thought Reasoning in Language Models.
Wang, X., et al. · 2022
Tree of Thoughts: Deliberate Problem Solving with Large Language Models.
Yao, S., et al. · 2023 · NeurIPS
ReAct: Synergizing Reasoning and Acting in Language Models.
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K., & Cao, Y. · 2023 · ICLR 2023
Questions and insights from fellow learners.