NoRaincheck

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)

<< Previous Post

|

Next Post >>

🎲 Random post

|

All posts

#LLM