[ad_1]
#LLM for beginners
Understand the basics of agents, tools, and prompts and some learnings along the way
Audience: For those feeling overwhelmed with the giant (yet brilliant) library…
I’d be lying if I said I have got the entire LangChain library covered — in fact, I am far from it. But the buzz surrounding it was enough to shake me out of my writing hiatus and give it a go 🚀.
The initial motivation was to see what was it that LangChain was adding (on a practical level) that set it apart from the chatbot I built last month using the ChatCompletion.create()
function from the openai
package. Whilst doing so, I realized I needed to understand the building blocks for LangChain first before moving on to the more complex parts.
This is what this article does. Heads-up though, this will be more parts coming as I am truly fascinated by the library and will continue to explore to see what all can be built through it.
Let’s begin by understanding the fundamental building blocks of LangChain — i.e. Chains. If you’d like to follow along, here’s the GitHub repo.
What are chains in LangChain?
Chains are what you get by connecting one or more large language models (LLMs) in a logical way. (Chains can be built of entities other than LLMs but for now, let’s stick with this definition for simplicity).
OpenAI is a type of LLM (provider) that you can use but there are others like Cohere, Bloom, Huggingface, etc.
Note: Pretty much most of these LLM providers will need you to request an API key in order to use them. So make sure you do that before proceeding with the remainder of this blog. For example:
import os
os.environ["OPENAI_API_KEY"] = "..."
P.S. I am going to use OpenAI for this tutorial because I have a key with credits that expire in a month’s time, but feel free to replace it with any other LLM. The concepts covered here will be useful regardless.
Chains can be simple (i.e. Generic) or specialized (i.e. Utility).
- Generic — A single LLM is the simplest chain. It takes an input prompt and the name of the LLM and then uses the LLM for text generation (i.e. output for the prompt). Here’s an example:
Let’s build a basic chain — create a prompt and get a prediction
Prompt creation (using PromptTemplate
) is a bit fancy in Lanchain but this is probably because there are quite a few different ways prompts can be created depending on the use case (we will cover AIMessagePromptTemplate
,HumanMessagePromptTemplate
etc. in the next blog post). Here’s a simple one for now:
from langchain.prompts import PromptTemplateprompt = PromptTemplate(
input_variables=["product"],
template="What is a good name for a company that makes product?",
)
print(prompt.format(product="podcast player"))
# OUTPUT
# What is a good name for a company that makes podcast player?
Note: If you require multiple input_variables
, for instance: input_variables=["product", "audience"]
for a template such as “What is a good name for a company that makes product for audience”
, you need to do print(prompt.format(product="podcast player", audience="children”)
to get the updated prompt.
Once you have built a prompt, we can call the desired LLM with it. To do so, we create an LLMChain
instance (in our case, we use OpenAI
‘s large language model text-davinci-003
). To get the prediction (i.e. AI-generated text), we use run
function with the name of the product
.
from langchain.llms import OpenAI
from langchain.chains import LLMChainllm = OpenAI(
model_name="text-davinci-003", # default model
temperature=0.9) #temperature dictates how whacky the output should be
llmchain = LLMChain(llm=llm, prompt=prompt)
llmchain.run("podcast player")
# OUTPUT
# PodConneXion
If you had more than one input_variables, then you won’t be able to use run
. Instead, you’ll have to pass all the variables as a dict
. For example, llmchain(“product”: “podcast player”, “audience”: “children”)
.
Note 1: According to OpenAI, davinci
text-generation models are 10x more expensive than their chat counterparts i.e gpt-3.5-turbo
, so I tried to switch from a text model to a chat model (i.e. from OpenAI
to ChatOpenAI
) and the results are pretty much the same.
Note 2: You might see some tutorials using OpenAIChat
instead of ChatOpenAI
. The former is deprecated and will no longer be supported and we are supposed to use ChatOpenAI
.
from langchain.chat_models import ChatOpenAIchatopenai = ChatOpenAI(
model_name="gpt-3.5-turbo")
llmchain_chat = LLMChain(llm=chatopenai, prompt=prompt)
llmchain_chat.run("podcast player")
# OUTPUT
# PodcastStream
This concludes our section on simple chains. It is important to note that we rarely use generic chains as standalone chains. More often they are used as building blocks for Utility chains (as we will see next).
2. Utility — These are specialized chains, comprised of many LLMs to help solve a specific task. For example, LangChain supports some end-to-end chains (such as AnalyzeDocumentChain
for summarization, QnA, etc) and some specific ones (such as GraphQnAChain
for creating, querying, and saving graphs). We will look at one specific chain called PalChain
in this tutorial for digging deeper.
PAL stands for Programme Aided Language Model. PALChain
reads complex math problems (described in natural language) and generates programs (for solving the math problem) as the intermediate reasoning steps, but offloads the solution step to a runtime such as a Python interpreter.
To confirm this is in fact true, we can inspect the _call()
in the base code here. Under the hood, we can see this chain:
P.S. It is a good practice to inspect _call()
in base.py
for any of the chains in LangChain to see how things are working under the hood.
from langchain.chains import PALChain
palchain = PALChain.from_math_prompt(llm=llm, verbose=True)
palchain.run("If my age is half of my dad's age and he is going to be 60 next year, what is my current age?")# OUTPUT
# > Entering new PALChain chain...
# def solution():
# """If my age is half of my dad's age and he is going to be 60 next year, what is my current age?"""
# dad_age_next_year = 60
# dad_age_now = dad_age_next_year - 1
# my_age_now = dad_age_now / 2
# result = my_age_now
# return result
#
# > Finished chain.
# '29.5'
Note1: verbose
can be set to False
if you do not need to see the intermediate step.
Now some of you may be wondering — but what about the prompt? We certainly didn’t pass one as we did for the generic llmchain
we built. The fact is, it is automatically loaded when using .from_math_prompt()
. You can check the default prompt using palchain.prompt.template
or you can directly inspect the prompt file here.
print(palchain.prompt.template)
# OUTPUT
# 'Q: Olivia has $23. She bought five bagels for $3 each. How much money does she have left?\n\n# solution in Python:\n\n\ndef solution():\n """Olivia has $23. She bought five bagels for $3 each. How much money does she have left?"""\n money_initial = 23\n bagels = 5\n bagel_cost = 3\n money_spent = bagels * bagel_cost\n money_left = money_initial - money_spent\n result = money_left\n return result\n\n\n\n\n\nQ: Michael had 58 golf balls. On tuesday, he lost 23 golf balls. On wednesday, he lost 2 more. How many golf balls did he have at the end of wednesday?\n\n# solution in Python:\n\n\ndef solution():\n """Michael had 58 golf balls. On tuesday, he lost 23 golf balls. On wednesday, he lost 2 more. How many golf balls did he have at the end of wednesday?"""\n golf_balls_initial = 58\n golf_balls_lost_tuesday = 23\n golf_balls_lost_wednesday = 2\n golf_balls_left = golf_balls_initial - golf_balls_lost_tuesday - golf_balls_lost_wednesday\n result = golf_balls_left\n return result\n\n\n\n\n\nQ: There were nine computers in the server room. Five more computers were installed each day, from monday to thursday. How many computers are now in the server room?\n\n# solution in Python:\n\n\ndef solution():\n """There were nine computers in the server room. Five more computers were installed each day, from monday to thursday. How many computers are now in the server room?"""\n computers_initial = 9\n computers_per_day = 5\n num_days = 4 # 4 days between monday and thursday\n computers_added = computers_per_day * num_days\n computers_total = computers_initial + computers_added\n result = computers_total\n return result\n\n\n\n\n\nQ: Shawn has five toys. For Christmas, he got two toys each from his mom and dad. How many toys does he have now?\n\n# solution in Python:\n\n\ndef solution():\n """Shawn has five toys. For Christmas, he got two toys each from his mom and dad. How many toys does he have now?"""\n toys_initial = 5\n mom_toys = 2\n dad_toys = 2\n total_received = mom_toys + dad_toys\n total_toys = toys_initial + total_received\n result = total_toys\n return result\n\n\n\n\n\nQ: Jason had 20 lollipops. He gave Denny some lollipops. Now Jason has 12 lollipops. How many lollipops did Jason give to Denny?\n\n# solution in Python:\n\n\ndef solution():\n """Jason had 20 lollipops. He gave Denny some lollipops. Now Jason has 12 lollipops. How many lollipops did Jason give to Denny?"""\n jason_lollipops_initial = 20\n jason_lollipops_after = 12\n denny_lollipops = jason_lollipops_initial - jason_lollipops_after\n result = denny_lollipops\n return result\n\n\n\n\n\nQ: Leah had 32 chocolates and her sister had 42. If they ate 35, how many pieces do they have left in total?\n\n# solution in Python:\n\n\ndef solution():\n """Leah had 32 chocolates and her sister had 42. If they ate 35, how many pieces do they have left in total?"""\n leah_chocolates = 32\n sister_chocolates = 42\n total_chocolates = leah_chocolates + sister_chocolates\n chocolates_eaten = 35\n chocolates_left = total_chocolates - chocolates_eaten\n result = chocolates_left\n return result\n\n\n\n\n\nQ: If there are 3 cars in the parking lot and 2 more cars arrive, how many cars are in the parking lot?\n\n# solution in Python:\n\n\ndef solution():\n """If there are 3 cars in the parking lot and 2 more cars arrive, how many cars are in the parking lot?"""\n cars_initial = 3\n cars_arrived = 2\n total_cars = cars_initial + cars_arrived\n result = total_cars\n return result\n\n\n\n\n\nQ: There are 15 trees in the grove. Grove workers will plant trees in the grove today. After they are done, there will be 21 trees. How many trees did the grove workers plant today?\n\n# solution in Python:\n\n\ndef solution():\n """There are 15 trees in the grove. Grove workers will plant trees in the grove today. After they are done, there will be 21 trees. How many trees did the grove workers plant today?"""\n trees_initial = 15\n trees_after = 21\n trees_added = trees_after - trees_initial\n result = trees_added\n return result\n\n\n\n\n\nQ: question\n\n# solution in Python:\n\n\n'
Note: Most of the utility chains will have their prompts pre-defined as part of the library (check them out here). They are, at times, quite detailed (read: lots of tokens) so there is definitely a trade-off between cost and the quality of response from the LLM.
Are there any Chains that don’t need LLMs and prompts?
Even though PalChain requires an LLM (and a corresponding prompt) to parse the user’s question written in natural language, there are some chains in LangChain that don’t need one. These are mainly transformation chains that preprocess the prompt, such as removing extra spaces, before inputting it into the LLM. You can see another example here.
Can we get to the good part and start creating chains?
Of course, we can! We have all the basic building blocks we need to start chaining together LLMs logically such that input from one can be fed to the next. To do so, we will use SimpleSequentialChain
.
The documentation has some great examples on this, for example, you can see here how to have two chains combined where chain#1 is used to clean the prompt (remove extra whitespaces, shorten prompt, etc) and chain#2 is used to call an LLM with this clean prompt. Here’s another one where chain#1 is used to generate a synopsis for a play and chain#2 is used to write a review based on this synopsis.
While these are excellent examples, I want to focus on something else. If you remember before, I mentioned that chains can be composed of entities other than LLMs. More specifically, I am interested in chaining agents and LLMs together. But first, what are agents?
Using agents for dynamically calling LLMs
It will be much easier to explain what an agent does vs. what it is.
Say, we want to know the weather forecast for tomorrow. If were to use the simple ChatGPT API and give it a prompt Show me the weather for tomorrow in London
, it won’t know the answer because it does not have access to real-time data.
Wouldn’t it be useful if we had an arrangement where we could utilize an LLM for understanding our query (i.e prompt) in natural language and then call the weather API on our behalf to fetch the data needed? This is exactly what an agent does (amongst other things, of course).
An agent has access to an LLM and a suite of tools for example Google Search, Python REPL, math calculator, weather APIs, etc.
There are quite a few agents that LangChain supports — see here for the complete list, but quite frankly the most common one I came across in tutorials and YT videos was zero-shot-react-description
. This agent uses ReAct (Reason + Act) framework to pick the most usable tool (from a list of tools), based on what the input query is.
P.S.: Here’s a nice article that goes in-depth into the ReAct framework.
Let’s initialize an agent using initialize_agent
and pass it the tools
and LLM
it needs. There’s a long list of tools available here that an agent can use to interact with the outside world. For our example, we are using the same math-solving tool as above, called pal-math
. This one requires an LLM at the time of initialization, so we pass to it the same OpenAI LLM instance as before.
from langchain.agents import initialize_agent
from langchain.agents import AgentType
from langchain.agents import load_toolsllm = OpenAI(temperature=0)
tools = load_tools(["pal-math"], llm=llm)
agent = initialize_agent(tools,
llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True)
Let’s test it out on the same example as above:
agent.run("If my age is half of my dad's age and he is going to be 60 next year, what is my current age?")# OUTPUT
# > Entering new AgentExecutor chain...
# I need to figure out my dad's current age and then divide it by two.
# Action: PAL-MATH
# Action Input: What is my dad's current age if he is going to be 60 next year?
# Observation: 59
# Thought: I now know my dad's current age, so I can divide it by two to get my age.
# Action: Divide 59 by 2
# Action Input: 59/2
# Observation: Divide 59 by 2 is not a valid tool, try another one.
# Thought: I can use PAL-MATH to divide 59 by 2.
# Action: PAL-MATH
# Action Input: Divide 59 by 2
# Observation: 29.5
# Thought: I now know the final answer.
# Final Answer: My current age is 29.5 years old.
# > Finished chain.
# 'My current age is 29.5 years old.'
Note 1: At each step, you’ll notice that an agent does one of three things — it either has an observation
, a thought
, or it takes an action
. This is mainly due to the ReAct framework and the associated prompt that the agent is using:
print(agent.agent.llm_chain.prompt.template)
# OUTPUT
# Answer the following questions as best you can. You have access to the following tools:
# PAL-MATH: A language model that is really good at solving complex word math problems. Input should be a fully worded hard word math problem.# Use the following format:
# Question: the input question you must answer
# Thought: you should always think about what to do
# Action: the action to take, should be one of [PAL-MATH]
# Action Input: the input to the action
# Observation: the result of the action
# ... (this Thought/Action/Action Input/Observation can repeat N times)
# Thought: I now know the final answer
# Final Answer: the final answer to the original input question
# Begin!
# Question: input
# Thought:agent_scratchpad
Note2: You might be wondering what’s the point of getting an agent to do the same thing that an LLM can do. Some applications will require not just a predetermined chain of calls to LLMs/other tools, but potentially an unknown chain that depends on the user’s input [Source]. In these types of chains, there is an “agent” which has access to a suite of tools.
For instance, here’s an example of an agent that can fetch the correct documents (from the vectorstores) for RetrievalQAChain
depending on whether the question refers to document A or document B.
For fun, I tried making the input question more complex (using Demi Moore’s age as a placeholder for Dad’s actual age).
agent.run("My age is half of my dad's age. Next year he is going to be same age as Demi Moore. What is my current age?")
Unfortunately, the answer was slightly off as the agent was not using the latest age for Demi Moore (since Open AI models were trained on data until 2020). This can be easily fixed by including another tool —tools = load_tools([“pal-math”, "serpapi"], llm=llm)
. serpapi
is useful for answering questions about current events.
Note: It is important to add as many tools as you think may be relevant to the user query. The problem with using a single tool is that the agent keeps trying to use the same tool even if it’s not the most relevant for a particular observation/action step.
Here’s another example of a tool you can use — podcast-api
. You need to get your own API key and plug it into the code below.
tools = load_tools(["podcast-api"], llm=llm, listen_api_key="...")
agent = initialize_agent(tools,
llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True)agent.run("Show me episodes for money saving tips.")
# OUTPUT
# > Entering new AgentExecutor chain...
# I should search for podcasts or episodes related to money saving
# Action: Podcast API
# Action Input: Money saving tips
# Observation: The API call returned 3 podcasts related to money saving tips: The Money Nerds, The Rachel Cruze Show, and The Martin Lewis Podcast. These podcasts offer valuable money saving tips and advice to help people take control of their finances and create a life they love.
# Thought: I now have some options to choose from
# Final Answer: The Money Nerds, The Rachel Cruze Show, and The Martin Lewis Podcast are great podcast options for money saving tips.
# > Finished chain.
# 'The Money Nerds, The Rachel Cruze Show, and The Martin Lewis Podcast are great podcast options for money saving tips.'
Note1: There is a known error with using this API where you might see, openai.error.InvalidRequestError: This model’s maximum context length is 4097 tokens, however you requested XXX tokens (XX in your prompt; XX for the completion). Please reduce your prompt; or completion length.
This happens when the response returned by the API might be too big. To work around this, the documentation suggests returning fewer search results, for example, by updating the question to "Show me episodes for money saving tips, return only 1 result"
.
Note2: While tinkering around with this tool, I noticed some inconsistencies. The responses aren’t always complete the first time around, for instance here are the input and responses from two consecutive runs:
Input: “Podcasts for getting better at French”
Response 1: “The best podcast for learning French is the one with the highest review score.”
Response 2: ‘The best podcast for learning French is “FrenchPod101”.
Under the hood, the tool is first using an LLMChain for building the API URL based on our input instructions (something along the lines of https://listen-api.listennotes.com/api/v2/search?q=french&type=podcast&page_size=3
) and making the API call. Upon receiving the response, it uses another LLMChain that summarizes the response to get the answer to our original question. You can check out the prompts here for both LLMchains which describe the process in more detail.
I am inclined to guess the inconsistent results seen above are resulting from the summarization step because I have separately debugged and tested the API URL (created by LLMChain#1) via Postman and received the right response. To further confirm my doubts, I also stress-tested the summarization chain as a standalone chain with an empty API URL hoping it would throw an error but got the response “Investing’ podcasts were found, containing 3 results in total.” 🤷♀ I’d be curious to see if others had better luck than me with this tool!
Use Case 2: Combine chains to create an age-appropriate gift generator
Let’s put our knowledge of agents and sequential chaining to good use and create our own sequential chain. We will combine:
- Chain #1 — The
agent
we just created that can solve age problems in math. - Chain #2 — An LLM that takes the age of a person and suggests an appropriate gift for them.
# Chain1 - solve math problem, get the age
chain_one = agent# Chain2 - suggest age-appropriate gift
template = """You are a gift recommender. Given a person's age,\n
it is your job to suggest an appropriate gift for them.
Person Age:
age
Suggest gift:"""
prompt_template = PromptTemplate(input_variables=["age"], template=template)
chain_two = LLMChain(llm=llm, prompt=prompt_template)
Now that we have both chains ready we can combine them using SimpleSequentialChain
.
from langchain.chains import SimpleSequentialChainoverall_chain = SimpleSequentialChain(
chains=[chain_one, chain_two],
verbose=True)
A couple of things to note:
- We need not explicitly pass
input_variables
andoutput_variables
forSimpleSequentialChain
as the underlying assumption is that the output from chain 1 is passed as input to chain 2.
Finally, we can run it with the same math problem as before:
question = "If my age is half of my dad's age and he is going to be 60 next year, what is my current age?"
overall_chain.run(question)# OUTPUT
# > Entering new SimpleSequentialChain chain...
# > Entering new AgentExecutor chain...
# I need to figure out my dad's current age and then divide it by two.
# Action: PAL-MATH
# Action Input: What is my dad's current age if he is going to be 60 next year?
# Observation: 59
# Thought: I now know my dad's current age, so I can divide it by two to get my age.
# Action: Divide 59 by 2
# Action Input: 59/2
# Observation: Divide 59 by 2 is not a valid tool, try another one.
# Thought: I need to use PAL-MATH to divide 59 by 2.
# Action: PAL-MATH
# Action Input: Divide 59 by 2
# Observation: 29.5
# Thought: I now know the final answer.
# Final Answer: My current age is 29.5 years old.
# > Finished chain.
# My current age is 29.5 years old.
# Given your age, a great gift would be something that you can use and enjoy now like a nice bottle of wine, a luxury watch, a cookbook, or a gift card to a favorite store or restaurant. Or, you could get something that will last for years like a nice piece of jewelry or a quality leather wallet.
# > Finished chain.
# '\nGiven your age, a great gift would be something that you can use and enjoy now like a nice bottle of wine, a luxury watch, a cookbook, or a gift card to a favorite store or restaurant. Or, you could get something that will last for years like a nice piece of jewelry or a quality leather wallet
There might be times when you need to pass along some additional context to the second chain, in addition to what it is receiving from the first chain. For instance, I want to set a budget for the gift, depending on the age of the person that is returned by the first chain. We can do so using SimpleMemory
.
First, let’s update the prompt for chain_two
and pass to it a second variable called budget
inside input_variables
.
template = """You are a gift recommender. Given a person's age,\n
it is your job to suggest an appropriate gift for them. If age is under 10,\n
the gift should cost no more than budget otherwise it should cost atleast 10 times budget.Person Age:
output
Suggest gift:"""
prompt_template = PromptTemplate(input_variables=["output", "budget"], template=template)
chain_two = LLMChain(llm=llm, prompt=prompt_template)
If you compare the template
we had for SimpleSequentialChain
with the one above, you’ll notice that I have also updated the first input’s variable name from age
→ output
. This is a crucial step, failing which an error would be raised at the time of chain validation — Missing required input keys: age, only had input, output, budget
.
This is because the output from the first entity in the chain (i.e. agent
) will be the input for the second entity in the chain (i.e. chain_two
) and therefore the variable names must match. Upon inspecting agent
’s output keys, we see that the output variable is called output
, hence the update.
print(agent.agent.llm_chain.output_keys)# OUTPUT
["output"]
Next, let’s update the kind of chain we are making. We can no longer work with SimpleSequentialChain
because it only works in cases where this is a single input and single output. Since chain_two
is now taking two input_variables
, we need to use SequentialChain
which is tailored to handle multiple inputs and outputs.
overall_chain = SequentialChain(
input_variables=["input"],
memory=SimpleMemory(memories="budget": "100 GBP"),
chains=[agent, chain_two],
verbose=True)
A couple of things to note:
- Unlike
SimpleSequentialChain
, passinginput_variables
parameter is mandatory forSequentialChain
. It is a list containing the name of the input variables that the first entity in the chain (i.e.agent
in our case) expects.
Now some of you may be wondering how to know the exact name used in the input prompt that theagent
is going to use. We certainly did not write the prompt for this agent (as we did forchain_two
)! It’s actually pretty straightforward to find it out by inspecting the prompt template of thellm_chain
that the agent is made up of.
print(agent.agent.llm_chain.prompt.template)# OUTPUT
#Answer the following questions as best you can. You have access to the following tools:
#PAL-MATH: A language model that is really good at solving complex word math problems. Input should be a fully worded hard word math problem.
#Use the following format:
#Question: the input question you must answer
#Thought: you should always think about what to do
#Action: the action to take, should be one of [PAL-MATH]
#Action Input: the input to the action
#Observation: the result of the action
#... (this Thought/Action/Action Input/Observation can repeat N times)
#Thought: I now know the final answer
#Final Answer: the final answer to the original input question
#Begin!
#Question: input
#Thought:agent_scratchpad
As you can see toward the end of the prompt, the questions being asked by the end-user is stored in an input variable by the name input
. If for some reason you had to manipulate this name in the prompt, make sure you are also updating the input_variables
at the time of the creation of SequentialChain
.
Finally, you could have found out the same information without going through the whole prompt:
print(agent.agent.llm_chain.prompt.input_variables)# OUTPUT
# ['input', 'agent_scratchpad']
SimpleMemory
is an easy way to store context or other bits of information that shouldn’t ever change between prompts. It requires one parameter at the time of initialization —memories
. You can pass elements to it indict
form. For instance,SimpleMemory(memories=“budget”: “100 GBP”)
.
Finally, let’s run the new chain with the same prompt as before. You will notice, the final output has some luxury gift recommendations such as weekend getaways in accordance with the higher budget in our updated prompt.
overall_chain.run("If my age is half of my dad's age and he is going to be 60 next year, what is my current age?")# OUTPUT
#> Entering new SequentialChain chain...
#> Entering new AgentExecutor chain...
# I need to figure out my dad's current age and then divide it by two.
#Action: PAL-MATH
#Action Input: What is my dad's current age if he is going to be 60 next year?
#Observation: 59
#Thought: I now know my dad's current age, so I can divide it by two to get my age.
#Action: Divide 59 by 2
#Action Input: 59/2
#Observation: Divide 59 by 2 is not a valid tool, try another one.
#Thought: I can use PAL-MATH to divide 59 by 2.
#Action: PAL-MATH
#Action Input: Divide 59 by 2
#Observation: 29.5
#Thought: I now know the final answer.
#Final Answer: My current age is 29.5 years old.
#> Finished chain.
# For someone of your age, a good gift would be something that is both practical and meaningful. Consider something like a nice watch, a piece of jewelry, a nice leather bag, or a gift card to a favorite store or restaurant.\nIf you have a larger budget, you could consider something like a weekend getaway, a spa package, or a special experience.'}
#> Finished chain.
For someone of your age, a good gift would be something that is both practical and meaningful. Consider something like a nice watch, a piece of jewelry, a nice leather bag, or a gift card to a favorite store or restaurant.\nIf you have a larger budget, you could consider something like a weekend getaway, a spa package, or a special experience.'}
[ad_2]
Source link