Using Outlines for LLM Constrained Generation
Using Outlines for LLM Constrained Generation
October 2025
Constrained generation is something that has interested me recently. Mostly as
an extension of structured generation. For example in the newest gpt-5 models
you can now have Regex as a constrained output. Now
outlines is not particularly new,
though what is interesting to me is the design of their APIs.
Chat Templates
Are created via:
1# Fill in nested templates
2prompt = chat_template(
3 system=system_template(instruction="You are a helpful assistant."),
4 user=user_template(query="What is machine learning?")
5)
Constrained outputs
…is as straightforward as:
1model(prompt, output_constraints)
These support dataclasses, pydantic, Regex and even
Literal[tuple(enums)].
Putting this together, we can attempt to get the LLM to play chess (albeit not very well). It does make me wonder how we may use LLMs to play games in a precise manner, e.g. if moves are injected in context to ‘promote’ them or dissuade an LLM from playing bad moves.
1import random
2import re
3from typing import Literal
4
5import chess
6import chess.pgn
7import outlines
8import tabulate
9from colorama import Fore, Style
10from llama_cpp import Llama
11from pydantic import BaseModel, Field
12
13model = outlines.from_llamacpp(Llama(model_path="gpt-oss-20b-mxfp4.gguf"))
14
15
16def print_board(board: chess.Board):
17 unicode_board = board.unicode(empty_square="·")
18 # change black to orange
19 for piece in "♜♞♝♛♚♟":
20 unicode_board = unicode_board.replace(piece, Fore.RED + piece + Style.RESET_ALL)
21 for piece in "♙♖♘♗♕♔":
22 unicode_board = unicode_board.replace(
23 piece, Fore.LIGHTCYAN_EX + piece + Style.RESET_ALL
24 )
25
26 # add row numbers
27 unicode_board = "\n".join(
28 [f"{8 - i} {line}" for i, line in enumerate(unicode_board.split("\n"))]
29 )
30 # add column letters
31 unicode_board = (
32 unicode_board
33 + "\n\n "
34 + "".join(
35 [f"{chr(97 + i)} " for i, line in enumerate(unicode_board.split("\n"))]
36 )
37 )
38 # print(unicode_board)
39
40 # pretty show the movelist
41 game = chess.pgn.Game()
42 game.add_line(board.move_stack)
43 exporter = chess.pgn.StringExporter(headers=False, variations=False, comments=False)
44 moves = game.accept(exporter)
45 moves = moves.replace("\n", " ")
46
47 # add a new line after each move number (e.g., "1.", "2.", etc.)
48 moves = re.sub(r"(\d+\.)", r"\n\1", moves).strip()
49
50 # take the last 10 moves only
51 if len(moves.split("\n")) > 10:
52 moves = "...\n" + "\n".join(moves.split("\n")[-9:])
53 else:
54 moves = moves
55
56 # pretty print
57 print(tabulate.tabulate([[unicode_board, moves]]))
58 return unicode_board
59
60
61class MoveInfo:
62 uci: str
63 description: str
64 piece_hash: str
65 san: str
66 root_sq: str
67 root_piece: str
68
69 def __init__(self, board: chess.Board, move: chess.Move):
70 self.uci = move.uci()
71 self.san = board.san(move)
72 self.root_sq = move.uci()[0:2]
73 self.root_piece = self.san[:-2]
74 self.piece_hash = self.compute_piece_hash()
75 self.description = self.get_description()
76
77 def compute_piece_hash(self) -> str:
78 if len(self.root_piece) < 1:
79 return ""
80
81 if self.root_piece[0].isupper():
82 return self.root_piece[0] + self.root_sq
83 else:
84 return ""
85
86 def get_description(self) -> str:
87 move_type = "capture" if "x" in self.san else "move"
88 if len(self.root_piece) < 1 or self.piece_hash == "":
89 return f"{self.san} - desc: pawn {move_type}"
90 else:
91 return f"{self.san} - desc: {self.root_piece[0]} {move_type}"
92
93 def __repr__(self) -> str:
94 return f"<MoveInfo: {self.description}>"
95
96
97def sample_moves(legal_moves: list[MoveInfo]):
98 # sample by piece_hash, return only 1 move per piece_hash
99 moves_by_hash = {}
100 for move in legal_moves:
101 moves_by_hash[move.piece_hash] = moves_by_hash.get(move.piece_hash, []) + [move]
102
103 sampled_moves = []
104 for hash, moves in moves_by_hash.items():
105 if len(moves) > 1:
106 sampled_moves.append(random.choice(moves))
107 else:
108 sampled_moves.append(moves[0])
109 return sampled_moves
110
111
112def create_chess_moves_class(
113 legal_moves: list[MoveInfo], sampled_moves: list[MoveInfo]
114):
115 # Dynamically creates a class for sentence classification scores with the given score attributes.
116
117 class LegalMoves(BaseModel):
118 move: str = Field(
119 description=f"The move to make. For example:\n\n{sampled_moves}",
120 json_schema_extra={"enum": [move.san for move in legal_moves]},
121 )
122
123 return LegalMoves
124
125
126def do_turn(board: chess.Board, assistant_prompt: str):
127 legal_moves = [MoveInfo(board, x) for x in board.legal_moves]
128 sampled_moves = sample_moves(legal_moves)
129 sampled_moves_description = "\n".join([move.description for move in sampled_moves])
130
131 # # using pydantic
132 # outcome = model(
133 # assistant_prompt + f". Board:\n\n{board}\n\nFEN: \n\n{board.fen()}\n\nSample moves:\n\n{sampled_moves_description}",
134 # create_chess_moves_class(legal_moves, sampled_moves)
135 # )
136 # import json
137 # legal_moves = [move for move in legal_moves if move.san == json.loads(outcome)['move']]
138
139 # using typing.literal
140 legal_move_strings = [move.san for move in legal_moves]
141 LegalMoveLiteral = Literal[tuple(legal_move_strings)]
142 outcome = model(
143 assistant_prompt
144 + f". Board:\n\n{board}\n\nFEN: \n\n{board.fen()}\n\nSample moves:\n\n{sampled_moves_description}",
145 LegalMoveLiteral,
146 )
147 legal_moves = [move for move in legal_moves if move.san == outcome]
148
149 if len(legal_moves) == 0:
150 raise ValueError(f"Illegal move: {outcome.move}")
151 else:
152 legal_move = legal_moves[0]
153
154 # make the move
155 board.push(chess.Move.from_uci(legal_move.uci))
156 return outcome
157
158
159def play_chess(board: chess.Board):
160 white_prompt = "You are a snarky chess bot. You are given a board and sample moves. You need to choose the best move, and provide some trash talking, as a sassy young girl."
161 black_prompt = "You are a friendly chess bot. You are given a board and sample moves. You need to choose the best move, and provide some encouraging words, as a friendly old man."
162 prompt = white_prompt if board.turn == chess.WHITE else black_prompt
163 return do_turn(board, prompt)
164
165
166# the object is dynamically generated since the enum changes all the time
167board = chess.Board()
168
169while board.outcome() is None:
170 print_board(board)
171 play_chess(board)
172
173print_board(board)
174print(board.outcome())
175
176game = chess.pgn.Game()
177game.add_line(board.move_stack)